Tools: Breaking: Containerized Asterisk — Production Docker Compose Stack
Tutorial 38: Containerized Asterisk — Production Docker Compose Stack
Table of Contents
1. Introduction
Why Containerize Asterisk?
When NOT to Containerize
Ideal Use Cases
2. Architecture Overview
Container Responsibilities
Directory Structure
3. Prerequisites
System Requirements
Install Docker and Docker Compose
Firewall Rules
Create Project Directory
4. Dockerfile Deep Dive
The Dockerfile
Entrypoint Script
Build and Test the Image
Build Arguments for Customization
5. Docker Compose Stack
Environment File
Docker Compose File
Starting the Stack
6. Asterisk Configuration Templates
PJSIP Configuration
Extensions (Dialplan)
Modules Configuration
HTTP Configuration (for ARI)
ARI Configuration
RTP Configuration
CDR Configuration
CDR Adaptive ODBC
ODBC Configuration
Voicemail Configuration
ConfBridge Configuration
7. RTP & NAT Challenges
The Problem
Solution 1: PJSIP NAT Settings (Bridge Networking)
Solution 2: Host Networking (Simplest)
Solution 3: Macvlan Networking
RTP Port Range: Why 10000-20000?
ICE/STUN/TURN for WebRTC
Debugging Audio Issues
Quick Decision Guide
8. Persistent Storage Strategy
What Needs to Persist
Bind Mounts vs Named Volumes
Recording Storage Architecture
Recording Cleanup Cron
Log Rotation
Full Backup Script
Restore Script
9. Database Integration
Database Init Script
PJSIP Realtime Configuration
Managing Endpoints via Database
ODBC Connection Pooling
10. ARI (Asterisk REST Interface)
ARI Architecture
ARI Application: Auto-Attendant
Running the ARI App as a Container
Testing ARI
11. WebRTC Support
Components Required
PJSIP WebSocket Transport
Coturn Configuration
Nginx WebSocket Proxy
Obtaining Let's Encrypt Certificates
Web Phone Client (SIP.js)
Testing WebRTC
12. Monitoring & Logging
Docker Health Checks
Prometheus Exporter for Asterisk
AMI Configuration for Exporter
Promtail Sidecar for Log Shipping
Grafana Dashboard
13. Scaling & High Availability
Architecture: Multiple Asterisk Instances Behind Kamailio
Docker Compose for Scaled Asterisk
Shared State with Redis
Recording Storage with NFS or S3
Blue-Green Deployments
14. Production Checklist & Troubleshooting
Pre-Deployment Checklist
Container Security Hardening
Common Issues and Solutions
Issue: No Audio (One-Way or Both Ways)
Issue: Registration Failures
Issue: Codec Negotiation Failure
Issue: DNS Resolution Inside Container
Issue: Container Keeps Restarting
Issue: Slow Container Startup with Large Port Range
Useful Operational Commands
File Reference Docker + Docker Compose + Asterisk 21 + PJSIP + MariaDB + Redis + Nginx + Let's Encrypt + ARI + WebRTC Running Asterisk on bare metal has been the standard for two decades, but the world has moved to containers. This tutorial builds a complete, production-ready Asterisk PBX stack using Docker Compose — from a multi-stage Dockerfile that compiles Asterisk from source with only the modules you need, through RTP/NAT handling (the hardest part of containerized VoIP), database-backed realtime configuration, ARI application development, WebRTC support with TURN, all the way to monitoring, scaling patterns, and a production hardening checklist. Every file is complete, copy-paste ready, and tested against real SIP trunks. Asterisk has traditionally been installed directly on bare metal or a VM — compiled from source, configured by hand, upgraded with crossed fingers. It works, but it comes with pain: Containers solve all of these: Containers are not always the right answer for Asterisk. Be honest about these limitations: Here is what we are building — a complete PBX stack that runs with a single docker compose up -d: Open these ports on your host firewall before proceeding: This is a multi-stage build that compiles Asterisk 21 from source with exactly the modules you need, then copies only the runtime binaries into a minimal image. The result is roughly 250 MB instead of the 1+ GB you would get installing everything. Create asterisk/Dockerfile: Create asterisk/entrypoint.sh: You can customize the build without editing the Dockerfile: This is the central file that ties everything together. Every service, network, volume, and dependency is defined here. Create .env in the project root: Create docker-compose.yml: Instead of hardcoding IP addresses and passwords in Asterisk config files, we use templates. The entrypoint script runs envsubst to replace ${VARIABLE} placeholders with values from the container's environment. Create asterisk/configs/pjsip.conf.template: Create asterisk/configs/extensions.conf.template: Create asterisk/configs/modules.conf (not templated — static): Create asterisk/configs/http.conf.template: Create asterisk/configs/ari.conf.template: Create asterisk/configs/rtp.conf (not templated): Create asterisk/configs/cdr.conf: Create asterisk/configs/cdr_adaptive_odbc.conf: Create asterisk/configs/res_odbc.conf.template: Create asterisk/configs/odbc.ini.template: Create asterisk/configs/voicemail.conf.template: Create asterisk/configs/confbridge.conf: This is the single hardest aspect of running Asterisk in Docker. If you get everything else right but NAT wrong, you will have one-way audio or no audio at all. This section explains why and gives you multiple solutions. When Asterisk runs inside a Docker container with bridge networking, it has a private IP address (e.g., 172.25.0.2). When it negotiates RTP with an external SIP peer, it tells the peer to send audio to 172.25.0.2 — which the peer cannot reach. The peer sends audio into the void, and you get one-way or no audio. This is what we configured in pjsip.conf.template. The key settings: How it works: Asterisk tells the remote peer "send audio to 198.51.100.10:15000" (the public IP). Docker's port mapping forwards UDP 15000 on the host to the container. rtp_symmetric ensures Asterisk sends its audio back to wherever the peer's audio came from, not to the SDP-advertised address. If NAT is giving you trouble, the simplest solution is to remove the network abstraction entirely: With host networking, Asterisk binds directly to the host's network interfaces. No port mapping, no Docker NAT, no bridge. SIP and RTP work exactly as they would on bare metal. Macvlan gives the container its own IP address on the physical network, like a real machine: When to use: You have spare public IPs and want full isolation without NAT. The container behaves exactly like a separate physical machine on the network. Caveat: The host cannot communicate with the macvlan container by default (a known Docker limitation). You need a macvlan bridge sub-interface on the host if other containers need to reach Asterisk. Each active call uses one RTP port (audio) plus optionally another for RTCP. The default range of 10000-20000 supports up to 5000 simultaneous calls. For smaller deployments, you can narrow this: And in docker-compose.yml: Important: Docker creates iptables rules for each mapped port. Mapping 10000 ports is fine on modern kernels, but if you see slow container startup, narrow the range. WebRTC clients are always behind NAT (they run in browsers). They use ICE (Interactive Connectivity Establishment) to discover a working path for media. This requires STUN and usually TURN servers. The Coturn container (configured later in Section 11) handles this. In rtp.conf, we point Asterisk at a STUN server: When you have no audio or one-way audio, debug in this order: Containers are ephemeral by design. When you docker compose down and up again, anything not in a volume is gone. For a PBX, losing recordings, voicemail, CDRs, or certificates would be catastrophic. This section maps every piece of persistent data to the right volume strategy. We use bind mounts for data you need to access directly from the host (recordings, logs) and named volumes for data managed entirely by Docker (database files). Recordings grow fast. A single call at G.711 (ulaw/alaw) generates about 1 MB per minute. With 100 concurrent calls averaging 5 minutes each, you generate roughly 30 GB per day. Plan accordingly. Recordings will fill your disk if you do not clean them up. Create scripts/cleanup-recordings.sh: Container logs are managed by Docker's json-file driver (configured in docker-compose.yml with max-size and max-file). But Asterisk also writes its own internal logs to /var/log/asterisk/. Handle those separately: Create scripts/logrotate-asterisk.conf: Create scripts/backup.sh: Create scripts/restore.sh: The MariaDB container stores CDRs, realtime PJSIP configuration (so you can manage endpoints without restarting Asterisk), voicemail metadata, and application state. Create mariadb/init/01-schema.sql: To tell Asterisk to read PJSIP configuration from the database instead of (or in addition to) config files, add this to pjsip.conf.template or create a separate sorcery.conf: Create asterisk/configs/sorcery.conf: Create asterisk/configs/extconfig.conf: With realtime configuration, you can add/modify/remove SIP endpoints without restarting Asterisk: The res_odbc.conf.template configures connection pooling. Key settings: Monitor connection health from the Asterisk CLI: ARI lets you control Asterisk calls from external applications via REST API and WebSocket events. This is the modern way to build telephony applications — your code handles the logic, Asterisk handles the media. This Python application implements a simple IVR using ARI. When a call enters the Stasis application (via exten => _7.,1,Stasis(autoattendant,...)), it plays a greeting, waits for DTMF input, and routes accordingly. Create ari-app/requirements.txt: Create ari-app/app.py: Add to docker-compose.yml: Create ari-app/Dockerfile: WebRTC lets users make and receive calls directly from a web browser — no SIP softphone needed. Asterisk acts as a WebRTC-to-SIP gateway, converting between browser-native WebRTC and traditional SIP/RTP. Already configured in Section 6 (transport-wss and endpoint-webrtc template). Key requirements: Create coturn/turnserver.conf: Create nginx/nginx.conf: Before starting the full stack, obtain certificates: Create a simple web phone using SIP.js. This goes in nginx/webphone/index.html (or served separately): A containerized Asterisk stack without monitoring is a black box. This section adds observability using the same tools from Tutorial 01 (Grafana + Prometheus + Loki). Already configured in docker-compose.yml. Verify: Create a sidecar exporter that exposes Asterisk metrics. This is a simplified version of Tutorial 08's exporter. Create asterisk/prometheus-exporter.py: Create asterisk/configs/manager.conf.template: Add Promtail to the Docker Compose stack to ship Asterisk logs to Loki: Create promtail/config.yml: Import or create a Grafana dashboard with these panels: A single Asterisk container handles hundreds of concurrent calls. But when you need more — multi-tenant hosting, geographic redundancy, or zero-downtime upgrades — you need scaling patterns. When running multiple Asterisk instances, use Redis for shared state: When running multiple Asterisk instances, recordings must go to a shared filesystem: Option B: S3-Compatible Storage Use a post-recording script to upload recordings to S3: Deploy new Asterisk versions without dropping calls: Use this checklist before going live: Root cause: Almost always NAT/RTP misconfiguration. What's Next: Tutorial 39 covers Asterisk security hardening — fail2ban integration, SIP TLS enforcement, SRTP, intrusion detection, and GeoIP-based call filtering for fraud prevention. Templates let you quickly answer FAQs or store snippets for re-use. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuseCode BlockCopy┌─────────────────────────────────────────────────────────────────────┐
│ Docker Compose Stack │
│ │
│ ┌─────────────┐ ┌──────────────┐ ┌─────────────────────┐ │
│ │ Nginx │ │ Asterisk │ │ MariaDB │ │
│ │ (reverse │◄──►│ 21 LTS │◄──►│ (CDR, realtime │ │
│ │ proxy, │ │ │ │ config, voicemail) │ │
│ │ TLS, │ │ PJSIP │ │ │ │
│ │ WebSocket)│ │ ARI │ └─────────────────────┘ │
│ └──────┬──────┘ │ ConfBridge │ │
│ │ │ Voicemail │ ┌─────────────────────┐ │
│ │ │ │◄──►│ Redis │ │
│ ┌──────┴──────┐ └──────┬───────┘ │ (ARI state, │ │
│ │ Certbot │ │ │ session cache) │ │
│ │ (Let's │ ┌──────┴───────┐ └─────────────────────┘ │
│ │ Encrypt) │ │ Volumes │ │
│ └─────────────┘ │ recordings/ │ ┌─────────────────────┐ │
│ │ voicemail/ │ │ Coturn │ │
│ │ logs/ │ │ (TURN/STUN for │ │
│ │ certs/ │ │ WebRTC NAT) │ │
│ └──────────────┘ └─────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘ External Traffic: ├── SIP/TLS (TCP 5061) ──────────────────► Asterisk PJSIP ├── SIP (UDP 5060) ─────────────────────► Asterisk PJSIP ├── RTP (UDP 10000-20000) ──────────────► Asterisk Media ├── HTTPS (TCP 443) ────────────────────► Nginx ──► ARI / WebRTC ├── HTTP (TCP 80) ──────────────────────► Nginx ──► Certbot / redirect └── TURN (UDP/TCP 3478, UDP 49152-65535) ► Coturn
┌─────────────────────────────────────────────────────────────────────┐
│ Docker Compose Stack │
│ │
│ ┌─────────────┐ ┌──────────────┐ ┌─────────────────────┐ │
│ │ Nginx │ │ Asterisk │ │ MariaDB │ │
│ │ (reverse │◄──►│ 21 LTS │◄──►│ (CDR, realtime │ │
│ │ proxy, │ │ │ │ config, voicemail) │ │
│ │ TLS, │ │ PJSIP │ │ │ │
│ │ WebSocket)│ │ ARI │ └─────────────────────┘ │
│ └──────┬──────┘ │ ConfBridge │ │
│ │ │ Voicemail │ ┌─────────────────────┐ │
│ │ │ │◄──►│ Redis │ │
│ ┌──────┴──────┐ └──────┬───────┘ │ (ARI state, │ │
│ │ Certbot │ │ │ session cache) │ │
│ │ (Let's │ ┌──────┴───────┐ └─────────────────────┘ │
│ │ Encrypt) │ │ Volumes │ │
│ └─────────────┘ │ recordings/ │ ┌─────────────────────┐ │
│ │ voicemail/ │ │ Coturn │ │
│ │ logs/ │ │ (TURN/STUN for │ │
│ │ certs/ │ │ WebRTC NAT) │ │
│ └──────────────┘ └─────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘ External Traffic: ├── SIP/TLS (TCP 5061) ──────────────────► Asterisk PJSIP ├── SIP (UDP 5060) ─────────────────────► Asterisk PJSIP ├── RTP (UDP 10000-20000) ──────────────► Asterisk Media ├── HTTPS (TCP 443) ────────────────────► Nginx ──► ARI / WebRTC ├── HTTP (TCP 80) ──────────────────────► Nginx ──► Certbot / redirect └── TURN (UDP/TCP 3478, UDP 49152-65535) ► Coturn
┌─────────────────────────────────────────────────────────────────────┐
│ Docker Compose Stack │
│ │
│ ┌─────────────┐ ┌──────────────┐ ┌─────────────────────┐ │
│ │ Nginx │ │ Asterisk │ │ MariaDB │ │
│ │ (reverse │◄──►│ 21 LTS │◄──►│ (CDR, realtime │ │
│ │ proxy, │ │ │ │ config, voicemail) │ │
│ │ TLS, │ │ PJSIP │ │ │ │
│ │ WebSocket)│ │ ARI │ └─────────────────────┘ │
│ └──────┬──────┘ │ ConfBridge │ │
│ │ │ Voicemail │ ┌─────────────────────┐ │
│ │ │ │◄──►│ Redis │ │
│ ┌──────┴──────┐ └──────┬───────┘ │ (ARI state, │ │
│ │ Certbot │ │ │ session cache) │ │
│ │ (Let's │ ┌──────┴───────┐ └─────────────────────┘ │
│ │ Encrypt) │ │ Volumes │ │
│ └─────────────┘ │ recordings/ │ ┌─────────────────────┐ │
│ │ voicemail/ │ │ Coturn │ │
│ │ logs/ │ │ (TURN/STUN for │ │
│ │ certs/ │ │ WebRTC NAT) │ │
│ └──────────────┘ └─────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘ External Traffic: ├── SIP/TLS (TCP 5061) ──────────────────► Asterisk PJSIP ├── SIP (UDP 5060) ─────────────────────► Asterisk PJSIP ├── RTP (UDP 10000-20000) ──────────────► Asterisk Media ├── HTTPS (TCP 443) ────────────────────► Nginx ──► ARI / WebRTC ├── HTTP (TCP 80) ──────────────────────► Nginx ──► Certbot / redirect └── TURN (UDP/TCP 3478, UDP 49152-65535) ► Coturn
asterisk-docker/
├── docker-compose.yml
├── .env
├── asterisk/
│ ├── Dockerfile
│ ├── entrypoint.sh
│ ├── configs/
│ │ ├── pjsip.conf.template
│ │ ├── extensions.conf.template
│ │ ├── modules.conf
│ │ ├── http.conf.template
│ │ ├── rtp.conf
│ │ ├── ari.conf.template
│ │ ├── cdr.conf
│ │ ├── cdr_adaptive_odbc.conf
│ │ ├── res_odbc.conf.template
│ │ ├── odbc.ini.template
│ │ └── voicemail.conf.template
│ └── sounds/
│ └── custom/
├── mariadb/
│ └── init/
│ └── 01-schema.sql
├── nginx/
│ ├── nginx.conf
│ └── conf.d/
│ └── asterisk.conf.template
├── coturn/
│ └── turnserver.conf.template
├── ari-app/
│ ├── requirements.txt
│ └── app.py
├── scripts/
│ ├── backup.sh
│ └── restore.sh
└── data/ # Created by Docker volumes ├── mariadb/ ├── redis/ ├── recordings/ ├── voicemail/ ├── logs/ └── certs/
asterisk-docker/
├── docker-compose.yml
├── .env
├── asterisk/
│ ├── Dockerfile
│ ├── entrypoint.sh
│ ├── configs/
│ │ ├── pjsip.conf.template
│ │ ├── extensions.conf.template
│ │ ├── modules.conf
│ │ ├── http.conf.template
│ │ ├── rtp.conf
│ │ ├── ari.conf.template
│ │ ├── cdr.conf
│ │ ├── cdr_adaptive_odbc.conf
│ │ ├── res_odbc.conf.template
│ │ ├── odbc.ini.template
│ │ └── voicemail.conf.template
│ └── sounds/
│ └── custom/
├── mariadb/
│ └── init/
│ └── 01-schema.sql
├── nginx/
│ ├── nginx.conf
│ └── conf.d/
│ └── asterisk.conf.template
├── coturn/
│ └── turnserver.conf.template
├── ari-app/
│ ├── requirements.txt
│ └── app.py
├── scripts/
│ ├── backup.sh
│ └── restore.sh
└── data/ # Created by Docker volumes ├── mariadb/ ├── redis/ ├── recordings/ ├── voicemail/ ├── logs/ └── certs/
asterisk-docker/
├── docker-compose.yml
├── .env
├── asterisk/
│ ├── Dockerfile
│ ├── entrypoint.sh
│ ├── configs/
│ │ ├── pjsip.conf.template
│ │ ├── extensions.conf.template
│ │ ├── modules.conf
│ │ ├── http.conf.template
│ │ ├── rtp.conf
│ │ ├── ari.conf.template
│ │ ├── cdr.conf
│ │ ├── cdr_adaptive_odbc.conf
│ │ ├── res_odbc.conf.template
│ │ ├── odbc.ini.template
│ │ └── voicemail.conf.template
│ └── sounds/
│ └── custom/
├── mariadb/
│ └── init/
│ └── 01-schema.sql
├── nginx/
│ ├── nginx.conf
│ └── conf.d/
│ └── asterisk.conf.template
├── coturn/
│ └── turnserver.conf.template
├── ari-app/
│ ├── requirements.txt
│ └── app.py
├── scripts/
│ ├── backup.sh
│ └── restore.sh
└── data/ # Created by Docker volumes ├── mariadb/ ├── redis/ ├── recordings/ ├── voicemail/ ├── logs/ └── certs/
# Remove old Docker packages
sudo apt-get remove -y docker docker-engine docker.io containerd runc 2>/dev/null # Install prerequisites
sudo apt-get update
sudo apt-get install -y ca-certificates curl gnupg # Add Docker GPG key
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | \ sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg # Add Docker repository
echo \ "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \ https://download.docker.com/linux/ubuntu \ $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \ sudo tee /etc/apt/sources.list.d/docker.list > /dev/null # Install Docker Engine + Compose plugin
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io \ docker-buildx-plugin docker-compose-plugin # Verify
docker --version # Docker 24.x or newer
docker compose version # v2.x
# Remove old Docker packages
sudo apt-get remove -y docker docker-engine docker.io containerd runc 2>/dev/null # Install prerequisites
sudo apt-get update
sudo apt-get install -y ca-certificates curl gnupg # Add Docker GPG key
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | \ sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg # Add Docker repository
echo \ "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \ https://download.docker.com/linux/ubuntu \ $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \ sudo tee /etc/apt/sources.list.d/docker.list > /dev/null # Install Docker Engine + Compose plugin
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io \ docker-buildx-plugin docker-compose-plugin # Verify
docker --version # Docker 24.x or newer
docker compose version # v2.x
# Remove old Docker packages
sudo apt-get remove -y docker docker-engine docker.io containerd runc 2>/dev/null # Install prerequisites
sudo apt-get update
sudo apt-get install -y ca-certificates curl gnupg # Add Docker GPG key
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | \ sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg # Add Docker repository
echo \ "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \ https://download.docker.com/linux/ubuntu \ $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \ sudo tee /etc/apt/sources.list.d/docker.list > /dev/null # Install Docker Engine + Compose plugin
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io \ docker-buildx-plugin docker-compose-plugin # Verify
docker --version # Docker 24.x or newer
docker compose version # v2.x
# SIP signaling
sudo ufw allow 5060/udp # SIP over UDP
sudo ufw allow 5060/tcp # SIP over TCP
sudo ufw allow 5061/tcp # SIP over TLS # RTP media
sudo ufw allow 10000:20000/udp # Web (HTTPS, HTTP for ACME challenge)
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp # TURN server (for WebRTC)
sudo ufw allow 3478/udp
sudo ufw allow 3478/tcp
sudo ufw allow 49152:65535/udp # Enable firewall
sudo ufw enable
# SIP signaling
sudo ufw allow 5060/udp # SIP over UDP
sudo ufw allow 5060/tcp # SIP over TCP
sudo ufw allow 5061/tcp # SIP over TLS # RTP media
sudo ufw allow 10000:20000/udp # Web (HTTPS, HTTP for ACME challenge)
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp # TURN server (for WebRTC)
sudo ufw allow 3478/udp
sudo ufw allow 3478/tcp
sudo ufw allow 49152:65535/udp # Enable firewall
sudo ufw enable
# SIP signaling
sudo ufw allow 5060/udp # SIP over UDP
sudo ufw allow 5060/tcp # SIP over TCP
sudo ufw allow 5061/tcp # SIP over TLS # RTP media
sudo ufw allow 10000:20000/udp # Web (HTTPS, HTTP for ACME challenge)
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp # TURN server (for WebRTC)
sudo ufw allow 3478/udp
sudo ufw allow 3478/tcp
sudo ufw allow 49152:65535/udp # Enable firewall
sudo ufw enable
mkdir -p /opt/asterisk-docker/{asterisk/{configs,sounds/custom},mariadb/init,nginx/conf.d,coturn,ari-app,scripts,data}
cd /opt/asterisk-docker
mkdir -p /opt/asterisk-docker/{asterisk/{configs,sounds/custom},mariadb/init,nginx/conf.d,coturn,ari-app,scripts,data}
cd /opt/asterisk-docker
mkdir -p /opt/asterisk-docker/{asterisk/{configs,sounds/custom},mariadb/init,nginx/conf.d,coturn,ari-app,scripts,data}
cd /opt/asterisk-docker
# =============================================================================
# Multi-stage Dockerfile for Asterisk 21 LTS
# Stage 1: Build Asterisk from source with selected modules
# Stage 2: Minimal runtime image with only what's needed
# ============================================================================= # -----------------------------------------------------------------------------
# Stage 1: Builder
# -----------------------------------------------------------------------------
FROM debian:bookworm-slim AS builder ARG ASTERISK_VERSION=21.7.0
ARG OPUS_VERSION=1.5.2 # Avoid interactive prompts during build
ENV DEBIAN_FRONTEND=noninteractive # Install build dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \ build-essential \ ca-certificates \ curl \ wget \ git \ pkg-config \ autoconf \ automake \ libtool \ # Asterisk core dependencies libjansson-dev \ libxml2-dev \ libncurses5-dev \ libsqlite3-dev \ uuid-dev \ libssl-dev \ # PJSIP dependencies libpjproject-dev \ # Sound/codec dependencies libspeex-dev \ libspeexdsp-dev \ libopus-dev \ libsndfile1-dev \ libvorbis-dev \ # Database/ODBC dependencies unixodbc-dev \ libmariadb-dev \ odbc-mariadb \ # Lua for dialplan (optional) liblua5.4-dev \ # HTTP/WebSocket dependencies (for ARI) libcurl4-openssl-dev \ # SRTP for encrypted media libsrtp2-dev \ # Editing/misc libedit-dev \ libxslt1-dev \ && rm -rf /var/lib/apt/lists/* # Download and extract Asterisk source
WORKDIR /usr/src
RUN curl -fsSL "https://downloads.asterisk.org/pub/telephony/asterisk/asterisk-${ASTERISK_VERSION}.tar.gz" \ -o asterisk.tar.gz \ && tar xzf asterisk.tar.gz \ && mv asterisk-${ASTERISK_VERSION} asterisk \ && rm asterisk.tar.gz WORKDIR /usr/src/asterisk # Install MP3 source (for mp3 playback support)
RUN contrib/scripts/get_mp3_source.sh || true # Configure Asterisk
# --with-pjproject-bundled downloads and builds pjproject matched to Asterisk version
# If system libpjproject is sufficient, use --with-pjproject instead
RUN ./configure \ --prefix=/usr \ --sysconfdir=/etc \ --localstatedir=/var \ --with-crypto \ --with-ssl \ --with-srtp \ --with-pjproject-bundled \ --with-jansson-bundled \ --with-opus \ --without-dahdi \ --without-pri \ --without-radius \ --without-postgres \ --without-sdl \ --without-gtk2 \ --without-x11 # Select modules to build using menuselect
# Enable: PJSIP, ARI, ConfBridge, Opus, Voicemail ODBC, CDR adaptive ODBC
# Disable: chan_sip (deprecated), DAHDI, unnecessary resource hogs
RUN make menuselect.makeopts \ # ---- Enable essential modules ---- && menuselect/menuselect \ --enable res_pjsip \ --enable res_pjsip_session \ --enable res_pjsip_authenticator_digest \ --enable res_pjsip_caller_id \ --enable res_pjsip_endpoint_identifier_ip \ --enable res_pjsip_endpoint_identifier_user \ --enable res_pjsip_header_funcs \ --enable res_pjsip_nat \ --enable res_pjsip_outbound_authenticator_digest \ --enable res_pjsip_outbound_registration \ --enable res_pjsip_registrar \ --enable res_pjsip_sdp_rtp \ --enable res_pjsip_transport_websocket \ --enable res_pjsip_dtmf_info \ # ARI and HTTP --enable res_ari \ --enable res_ari_channels \ --enable res_ari_bridges \ --enable res_ari_endpoints \ --enable res_ari_recordings \ --enable res_ari_playbacks \ --enable res_ari_sounds \ --enable res_ari_events \ --enable res_http_websocket \ --enable res_stasis \ --enable res_stasis_answer \ --enable res_stasis_playback \ --enable res_stasis_recording \ --enable res_stasis_snoop \ # Conferencing (ConfBridge, no DAHDI needed) --enable app_confbridge \ --enable app_audiosocket \ # Codecs --enable codec_opus \ --enable codec_speex \ --enable codec_resample \ # Database / CDR --enable cdr_adaptive_odbc \ --enable cdr_csv \ --enable func_odbc \ --enable res_odbc \ --enable res_odbc_transaction \ # Voicemail with ODBC storage --enable app_voicemail_odbc \ # Timing (timerfd works without DAHDI) --enable res_timing_timerfd \ # Format support --enable format_mp3 \ --enable format_wav \ --enable format_wav_gsm \ --enable format_sln \ --enable format_ogg_vorbis \ # ---- Disable what we don't need ---- --disable chan_sip \ --disable chan_dahdi \ --disable chan_skinny \ --disable chan_mgcp \ --disable chan_unistim \ --disable app_meetme \ --disable res_timing_dahdi \ --disable cdr_pgsql \ --disable cel_pgsql \ menuselect.makeopts # Build Asterisk (use all available cores)
RUN make -j$(nproc) # Install to staging directory
RUN make install DESTDIR=/tmp/asterisk-install \ && make samples DESTDIR=/tmp/asterisk-install # Download standard English sound files (GSM + WAV formats)
RUN make progdocs || true # Download core sound packs to staging
RUN mkdir -p /tmp/asterisk-install/var/lib/asterisk/sounds/en \ && cd /tmp/asterisk-install/var/lib/asterisk/sounds/en \ && for pack in core extra; do \ for fmt in gsm wav; do \ curl -fsSL "https://downloads.asterisk.org/pub/telephony/sounds/asterisk-${pack}-sounds-en-${fmt}-current.tar.gz" \ | tar xz 2>/dev/null || true; \ done; \ done # Download MOH files
RUN mkdir -p /tmp/asterisk-install/var/lib/asterisk/moh \ && cd /tmp/asterisk-install/var/lib/asterisk/moh \ && curl -fsSL "https://downloads.asterisk.org/pub/telephony/sounds/asterisk-moh-opsound-wav-current.tar.gz" \ | tar xz 2>/dev/null || true # -----------------------------------------------------------------------------
# Stage 2: Runtime
# -----------------------------------------------------------------------------
FROM debian:bookworm-slim AS runtime LABEL maintainer="[email protected]"
LABEL description="Asterisk 21 LTS - Production Container"
LABEL version="1.0" ENV DEBIAN_FRONTEND=noninteractive # Install only runtime dependencies (no compilers, no -dev packages)
RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates \ curl \ # Core runtime libs libjansson4 \ libxml2 \ libncurses6 \ libsqlite3-0 \ libuuid1 \ libssl3 \ # Codec runtime libs libspeex1 \ libspeexdsp1 \ libopus0 \ libsndfile1 \ libvorbis0a \ libvorbisenc2 \ # Database/ODBC runtime unixodbc \ libmariadb3 \ odbc-mariadb \ # SRTP libsrtp2-1 \ # HTTP libcurl4 \ # Editing libedit2 \ # Lua runtime liblua5.4-0 \ # envsubst for config templating gettext-base \ # Useful debugging tools (remove in ultra-minimal builds) sngrep \ tcpdump \ net-tools \ iputils-ping \ dnsutils \ procps \ && rm -rf /var/lib/apt/lists/* # Create asterisk user and group
RUN groupadd -r asterisk && useradd -r -g asterisk -s /bin/false asterisk # Copy compiled Asterisk from builder stage
COPY --from=builder /tmp/asterisk-install/ / # Create required directories with proper ownership
RUN mkdir -p \ /var/run/asterisk \ /var/log/asterisk \ /var/log/asterisk/cdr-csv \ /var/spool/asterisk/monitor \ /var/spool/asterisk/voicemail \ /var/spool/asterisk/tmp \ /var/lib/asterisk \ /etc/asterisk \ && chown -R asterisk:asterisk \ /var/run/asterisk \ /var/log/asterisk \ /var/spool/asterisk \ /var/lib/asterisk \ /etc/asterisk # Copy entrypoint script
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh # Health check - verify Asterisk is responding
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ CMD asterisk -rx "core show version" || exit 1 # Expose ports
# 5060: SIP UDP/TCP
# 5061: SIP TLS
# 8088: HTTP/ARI/WebSocket
# 8089: HTTPS/ARI/WebSocket
# 5038: AMI
# 10000-20000: RTP media
EXPOSE 5060/udp 5060/tcp 5061/tcp 8088/tcp 8089/tcp 5038/tcp
EXPOSE 10000-20000/udp # Run as root initially (entrypoint drops privileges)
ENTRYPOINT ["/entrypoint.sh"]
CMD ["asterisk", "-fp"]
# =============================================================================
# Multi-stage Dockerfile for Asterisk 21 LTS
# Stage 1: Build Asterisk from source with selected modules
# Stage 2: Minimal runtime image with only what's needed
# ============================================================================= # -----------------------------------------------------------------------------
# Stage 1: Builder
# -----------------------------------------------------------------------------
FROM debian:bookworm-slim AS builder ARG ASTERISK_VERSION=21.7.0
ARG OPUS_VERSION=1.5.2 # Avoid interactive prompts during build
ENV DEBIAN_FRONTEND=noninteractive # Install build dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \ build-essential \ ca-certificates \ curl \ wget \ git \ pkg-config \ autoconf \ automake \ libtool \ # Asterisk core dependencies libjansson-dev \ libxml2-dev \ libncurses5-dev \ libsqlite3-dev \ uuid-dev \ libssl-dev \ # PJSIP dependencies libpjproject-dev \ # Sound/codec dependencies libspeex-dev \ libspeexdsp-dev \ libopus-dev \ libsndfile1-dev \ libvorbis-dev \ # Database/ODBC dependencies unixodbc-dev \ libmariadb-dev \ odbc-mariadb \ # Lua for dialplan (optional) liblua5.4-dev \ # HTTP/WebSocket dependencies (for ARI) libcurl4-openssl-dev \ # SRTP for encrypted media libsrtp2-dev \ # Editing/misc libedit-dev \ libxslt1-dev \ && rm -rf /var/lib/apt/lists/* # Download and extract Asterisk source
WORKDIR /usr/src
RUN curl -fsSL "https://downloads.asterisk.org/pub/telephony/asterisk/asterisk-${ASTERISK_VERSION}.tar.gz" \ -o asterisk.tar.gz \ && tar xzf asterisk.tar.gz \ && mv asterisk-${ASTERISK_VERSION} asterisk \ && rm asterisk.tar.gz WORKDIR /usr/src/asterisk # Install MP3 source (for mp3 playback support)
RUN contrib/scripts/get_mp3_source.sh || true # Configure Asterisk
# --with-pjproject-bundled downloads and builds pjproject matched to Asterisk version
# If system libpjproject is sufficient, use --with-pjproject instead
RUN ./configure \ --prefix=/usr \ --sysconfdir=/etc \ --localstatedir=/var \ --with-crypto \ --with-ssl \ --with-srtp \ --with-pjproject-bundled \ --with-jansson-bundled \ --with-opus \ --without-dahdi \ --without-pri \ --without-radius \ --without-postgres \ --without-sdl \ --without-gtk2 \ --without-x11 # Select modules to build using menuselect
# Enable: PJSIP, ARI, ConfBridge, Opus, Voicemail ODBC, CDR adaptive ODBC
# Disable: chan_sip (deprecated), DAHDI, unnecessary resource hogs
RUN make menuselect.makeopts \ # ---- Enable essential modules ---- && menuselect/menuselect \ --enable res_pjsip \ --enable res_pjsip_session \ --enable res_pjsip_authenticator_digest \ --enable res_pjsip_caller_id \ --enable res_pjsip_endpoint_identifier_ip \ --enable res_pjsip_endpoint_identifier_user \ --enable res_pjsip_header_funcs \ --enable res_pjsip_nat \ --enable res_pjsip_outbound_authenticator_digest \ --enable res_pjsip_outbound_registration \ --enable res_pjsip_registrar \ --enable res_pjsip_sdp_rtp \ --enable res_pjsip_transport_websocket \ --enable res_pjsip_dtmf_info \ # ARI and HTTP --enable res_ari \ --enable res_ari_channels \ --enable res_ari_bridges \ --enable res_ari_endpoints \ --enable res_ari_recordings \ --enable res_ari_playbacks \ --enable res_ari_sounds \ --enable res_ari_events \ --enable res_http_websocket \ --enable res_stasis \ --enable res_stasis_answer \ --enable res_stasis_playback \ --enable res_stasis_recording \ --enable res_stasis_snoop \ # Conferencing (ConfBridge, no DAHDI needed) --enable app_confbridge \ --enable app_audiosocket \ # Codecs --enable codec_opus \ --enable codec_speex \ --enable codec_resample \ # Database / CDR --enable cdr_adaptive_odbc \ --enable cdr_csv \ --enable func_odbc \ --enable res_odbc \ --enable res_odbc_transaction \ # Voicemail with ODBC storage --enable app_voicemail_odbc \ # Timing (timerfd works without DAHDI) --enable res_timing_timerfd \ # Format support --enable format_mp3 \ --enable format_wav \ --enable format_wav_gsm \ --enable format_sln \ --enable format_ogg_vorbis \ # ---- Disable what we don't need ---- --disable chan_sip \ --disable chan_dahdi \ --disable chan_skinny \ --disable chan_mgcp \ --disable chan_unistim \ --disable app_meetme \ --disable res_timing_dahdi \ --disable cdr_pgsql \ --disable cel_pgsql \ menuselect.makeopts # Build Asterisk (use all available cores)
RUN make -j$(nproc) # Install to staging directory
RUN make install DESTDIR=/tmp/asterisk-install \ && make samples DESTDIR=/tmp/asterisk-install # Download standard English sound files (GSM + WAV formats)
RUN make progdocs || true # Download core sound packs to staging
RUN mkdir -p /tmp/asterisk-install/var/lib/asterisk/sounds/en \ && cd /tmp/asterisk-install/var/lib/asterisk/sounds/en \ && for pack in core extra; do \ for fmt in gsm wav; do \ curl -fsSL "https://downloads.asterisk.org/pub/telephony/sounds/asterisk-${pack}-sounds-en-${fmt}-current.tar.gz" \ | tar xz 2>/dev/null || true; \ done; \ done # Download MOH files
RUN mkdir -p /tmp/asterisk-install/var/lib/asterisk/moh \ && cd /tmp/asterisk-install/var/lib/asterisk/moh \ && curl -fsSL "https://downloads.asterisk.org/pub/telephony/sounds/asterisk-moh-opsound-wav-current.tar.gz" \ | tar xz 2>/dev/null || true # -----------------------------------------------------------------------------
# Stage 2: Runtime
# -----------------------------------------------------------------------------
FROM debian:bookworm-slim AS runtime LABEL maintainer="[email protected]"
LABEL description="Asterisk 21 LTS - Production Container"
LABEL version="1.0" ENV DEBIAN_FRONTEND=noninteractive # Install only runtime dependencies (no compilers, no -dev packages)
RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates \ curl \ # Core runtime libs libjansson4 \ libxml2 \ libncurses6 \ libsqlite3-0 \ libuuid1 \ libssl3 \ # Codec runtime libs libspeex1 \ libspeexdsp1 \ libopus0 \ libsndfile1 \ libvorbis0a \ libvorbisenc2 \ # Database/ODBC runtime unixodbc \ libmariadb3 \ odbc-mariadb \ # SRTP libsrtp2-1 \ # HTTP libcurl4 \ # Editing libedit2 \ # Lua runtime liblua5.4-0 \ # envsubst for config templating gettext-base \ # Useful debugging tools (remove in ultra-minimal builds) sngrep \ tcpdump \ net-tools \ iputils-ping \ dnsutils \ procps \ && rm -rf /var/lib/apt/lists/* # Create asterisk user and group
RUN groupadd -r asterisk && useradd -r -g asterisk -s /bin/false asterisk # Copy compiled Asterisk from builder stage
COPY --from=builder /tmp/asterisk-install/ / # Create required directories with proper ownership
RUN mkdir -p \ /var/run/asterisk \ /var/log/asterisk \ /var/log/asterisk/cdr-csv \ /var/spool/asterisk/monitor \ /var/spool/asterisk/voicemail \ /var/spool/asterisk/tmp \ /var/lib/asterisk \ /etc/asterisk \ && chown -R asterisk:asterisk \ /var/run/asterisk \ /var/log/asterisk \ /var/spool/asterisk \ /var/lib/asterisk \ /etc/asterisk # Copy entrypoint script
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh # Health check - verify Asterisk is responding
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ CMD asterisk -rx "core show version" || exit 1 # Expose ports
# 5060: SIP UDP/TCP
# 5061: SIP TLS
# 8088: HTTP/ARI/WebSocket
# 8089: HTTPS/ARI/WebSocket
# 5038: AMI
# 10000-20000: RTP media
EXPOSE 5060/udp 5060/tcp 5061/tcp 8088/tcp 8089/tcp 5038/tcp
EXPOSE 10000-20000/udp # Run as root initially (entrypoint drops privileges)
ENTRYPOINT ["/entrypoint.sh"]
CMD ["asterisk", "-fp"]
# =============================================================================
# Multi-stage Dockerfile for Asterisk 21 LTS
# Stage 1: Build Asterisk from source with selected modules
# Stage 2: Minimal runtime image with only what's needed
# ============================================================================= # -----------------------------------------------------------------------------
# Stage 1: Builder
# -----------------------------------------------------------------------------
FROM debian:bookworm-slim AS builder ARG ASTERISK_VERSION=21.7.0
ARG OPUS_VERSION=1.5.2 # Avoid interactive prompts during build
ENV DEBIAN_FRONTEND=noninteractive # Install build dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \ build-essential \ ca-certificates \ curl \ wget \ git \ pkg-config \ autoconf \ automake \ libtool \ # Asterisk core dependencies libjansson-dev \ libxml2-dev \ libncurses5-dev \ libsqlite3-dev \ uuid-dev \ libssl-dev \ # PJSIP dependencies libpjproject-dev \ # Sound/codec dependencies libspeex-dev \ libspeexdsp-dev \ libopus-dev \ libsndfile1-dev \ libvorbis-dev \ # Database/ODBC dependencies unixodbc-dev \ libmariadb-dev \ odbc-mariadb \ # Lua for dialplan (optional) liblua5.4-dev \ # HTTP/WebSocket dependencies (for ARI) libcurl4-openssl-dev \ # SRTP for encrypted media libsrtp2-dev \ # Editing/misc libedit-dev \ libxslt1-dev \ && rm -rf /var/lib/apt/lists/* # Download and extract Asterisk source
WORKDIR /usr/src
RUN curl -fsSL "https://downloads.asterisk.org/pub/telephony/asterisk/asterisk-${ASTERISK_VERSION}.tar.gz" \ -o asterisk.tar.gz \ && tar xzf asterisk.tar.gz \ && mv asterisk-${ASTERISK_VERSION} asterisk \ && rm asterisk.tar.gz WORKDIR /usr/src/asterisk # Install MP3 source (for mp3 playback support)
RUN contrib/scripts/get_mp3_source.sh || true # Configure Asterisk
# --with-pjproject-bundled downloads and builds pjproject matched to Asterisk version
# If system libpjproject is sufficient, use --with-pjproject instead
RUN ./configure \ --prefix=/usr \ --sysconfdir=/etc \ --localstatedir=/var \ --with-crypto \ --with-ssl \ --with-srtp \ --with-pjproject-bundled \ --with-jansson-bundled \ --with-opus \ --without-dahdi \ --without-pri \ --without-radius \ --without-postgres \ --without-sdl \ --without-gtk2 \ --without-x11 # Select modules to build using menuselect
# Enable: PJSIP, ARI, ConfBridge, Opus, Voicemail ODBC, CDR adaptive ODBC
# Disable: chan_sip (deprecated), DAHDI, unnecessary resource hogs
RUN make menuselect.makeopts \ # ---- Enable essential modules ---- && menuselect/menuselect \ --enable res_pjsip \ --enable res_pjsip_session \ --enable res_pjsip_authenticator_digest \ --enable res_pjsip_caller_id \ --enable res_pjsip_endpoint_identifier_ip \ --enable res_pjsip_endpoint_identifier_user \ --enable res_pjsip_header_funcs \ --enable res_pjsip_nat \ --enable res_pjsip_outbound_authenticator_digest \ --enable res_pjsip_outbound_registration \ --enable res_pjsip_registrar \ --enable res_pjsip_sdp_rtp \ --enable res_pjsip_transport_websocket \ --enable res_pjsip_dtmf_info \ # ARI and HTTP --enable res_ari \ --enable res_ari_channels \ --enable res_ari_bridges \ --enable res_ari_endpoints \ --enable res_ari_recordings \ --enable res_ari_playbacks \ --enable res_ari_sounds \ --enable res_ari_events \ --enable res_http_websocket \ --enable res_stasis \ --enable res_stasis_answer \ --enable res_stasis_playback \ --enable res_stasis_recording \ --enable res_stasis_snoop \ # Conferencing (ConfBridge, no DAHDI needed) --enable app_confbridge \ --enable app_audiosocket \ # Codecs --enable codec_opus \ --enable codec_speex \ --enable codec_resample \ # Database / CDR --enable cdr_adaptive_odbc \ --enable cdr_csv \ --enable func_odbc \ --enable res_odbc \ --enable res_odbc_transaction \ # Voicemail with ODBC storage --enable app_voicemail_odbc \ # Timing (timerfd works without DAHDI) --enable res_timing_timerfd \ # Format support --enable format_mp3 \ --enable format_wav \ --enable format_wav_gsm \ --enable format_sln \ --enable format_ogg_vorbis \ # ---- Disable what we don't need ---- --disable chan_sip \ --disable chan_dahdi \ --disable chan_skinny \ --disable chan_mgcp \ --disable chan_unistim \ --disable app_meetme \ --disable res_timing_dahdi \ --disable cdr_pgsql \ --disable cel_pgsql \ menuselect.makeopts # Build Asterisk (use all available cores)
RUN make -j$(nproc) # Install to staging directory
RUN make install DESTDIR=/tmp/asterisk-install \ && make samples DESTDIR=/tmp/asterisk-install # Download standard English sound files (GSM + WAV formats)
RUN make progdocs || true # Download core sound packs to staging
RUN mkdir -p /tmp/asterisk-install/var/lib/asterisk/sounds/en \ && cd /tmp/asterisk-install/var/lib/asterisk/sounds/en \ && for pack in core extra; do \ for fmt in gsm wav; do \ curl -fsSL "https://downloads.asterisk.org/pub/telephony/sounds/asterisk-${pack}-sounds-en-${fmt}-current.tar.gz" \ | tar xz 2>/dev/null || true; \ done; \ done # Download MOH files
RUN mkdir -p /tmp/asterisk-install/var/lib/asterisk/moh \ && cd /tmp/asterisk-install/var/lib/asterisk/moh \ && curl -fsSL "https://downloads.asterisk.org/pub/telephony/sounds/asterisk-moh-opsound-wav-current.tar.gz" \ | tar xz 2>/dev/null || true # -----------------------------------------------------------------------------
# Stage 2: Runtime
# -----------------------------------------------------------------------------
FROM debian:bookworm-slim AS runtime LABEL maintainer="[email protected]"
LABEL description="Asterisk 21 LTS - Production Container"
LABEL version="1.0" ENV DEBIAN_FRONTEND=noninteractive # Install only runtime dependencies (no compilers, no -dev packages)
RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates \ curl \ # Core runtime libs libjansson4 \ libxml2 \ libncurses6 \ libsqlite3-0 \ libuuid1 \ libssl3 \ # Codec runtime libs libspeex1 \ libspeexdsp1 \ libopus0 \ libsndfile1 \ libvorbis0a \ libvorbisenc2 \ # Database/ODBC runtime unixodbc \ libmariadb3 \ odbc-mariadb \ # SRTP libsrtp2-1 \ # HTTP libcurl4 \ # Editing libedit2 \ # Lua runtime liblua5.4-0 \ # envsubst for config templating gettext-base \ # Useful debugging tools (remove in ultra-minimal builds) sngrep \ tcpdump \ net-tools \ iputils-ping \ dnsutils \ procps \ && rm -rf /var/lib/apt/lists/* # Create asterisk user and group
RUN groupadd -r asterisk && useradd -r -g asterisk -s /bin/false asterisk # Copy compiled Asterisk from builder stage
COPY --from=builder /tmp/asterisk-install/ / # Create required directories with proper ownership
RUN mkdir -p \ /var/run/asterisk \ /var/log/asterisk \ /var/log/asterisk/cdr-csv \ /var/spool/asterisk/monitor \ /var/spool/asterisk/voicemail \ /var/spool/asterisk/tmp \ /var/lib/asterisk \ /etc/asterisk \ && chown -R asterisk:asterisk \ /var/run/asterisk \ /var/log/asterisk \ /var/spool/asterisk \ /var/lib/asterisk \ /etc/asterisk # Copy entrypoint script
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh # Health check - verify Asterisk is responding
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ CMD asterisk -rx "core show version" || exit 1 # Expose ports
# 5060: SIP UDP/TCP
# 5061: SIP TLS
# 8088: HTTP/ARI/WebSocket
# 8089: HTTPS/ARI/WebSocket
# 5038: AMI
# 10000-20000: RTP media
EXPOSE 5060/udp 5060/tcp 5061/tcp 8088/tcp 8089/tcp 5038/tcp
EXPOSE 10000-20000/udp # Run as root initially (entrypoint drops privileges)
ENTRYPOINT ["/entrypoint.sh"]
CMD ["asterisk", "-fp"]
#!/bin/bash
set -e # =============================================================================
# Asterisk Container Entrypoint
# - Processes config templates (envsubst)
# - Sets up ODBC configuration
# - Fixes permissions
# - Starts Asterisk with proper flags
# ============================================================================= echo "=== Asterisk Container Starting ==="
echo "Hostname: $(hostname)"
echo "Date: $(date -u '+%Y-%m-%d %H:%M:%S UTC')" # ---- Step 1: Process configuration templates ----
# Any file ending in .template gets processed with envsubst
# Environment variables like ${PJSIP_EXTERNAL_IP} are replaced with values
echo "Processing configuration templates..." TEMPLATE_DIR="/etc/asterisk/templates"
CONFIG_DIR="/etc/asterisk" if [ -d "$TEMPLATE_DIR" ]; then for template in "$TEMPLATE_DIR"/*.template; do [ -f "$template" ] || continue filename=$(basename "$template" .template) echo " Rendering: $filename" envsubst < "$template" > "$CONFIG_DIR/$filename" done
fi # ---- Step 2: Process ODBC configuration ----
if [ -f "/etc/odbc.ini.template" ]; then echo "Rendering ODBC configuration..." envsubst < /etc/odbc.ini.template > /etc/odbc.ini
fi # ---- Step 3: Set NAT/network variables ----
# Auto-detect public IP if not provided
if [ -z "$PJSIP_EXTERNAL_IP" ]; then echo "PJSIP_EXTERNAL_IP not set, auto-detecting..." PJSIP_EXTERNAL_IP=$(curl -s -4 --max-time 5 https://ifconfig.me || echo "") if [ -n "$PJSIP_EXTERNAL_IP" ]; then echo " Detected external IP: $PJSIP_EXTERNAL_IP" export PJSIP_EXTERNAL_IP else echo " WARNING: Could not detect external IP. NAT may not work correctly." fi
fi # Re-process templates now that PJSIP_EXTERNAL_IP might be set
if [ -d "$TEMPLATE_DIR" ]; then for template in "$TEMPLATE_DIR"/*.template; do [ -f "$template" ] || continue filename=$(basename "$template" .template) envsubst < "$template" > "$CONFIG_DIR/$filename" done
fi # ---- Step 4: Fix permissions ----
echo "Setting permissions..."
chown -R asterisk:asterisk /var/run/asterisk
chown -R asterisk:asterisk /var/log/asterisk
chown -R asterisk:asterisk /var/spool/asterisk
chown -R asterisk:asterisk /etc/asterisk
chown -R asterisk:asterisk /var/lib/asterisk # ---- Step 5: Wait for database ----
if [ -n "$DB_HOST" ]; then echo "Waiting for database at $DB_HOST:${DB_PORT:-3306}..." for i in $(seq 1 30); do if curl -sf "telnet://$DB_HOST:${DB_PORT:-3306}" --max-time 2 >/dev/null 2>&1 || \ bash -c "echo >/dev/tcp/$DB_HOST/${DB_PORT:-3306}" 2>/dev/null; then echo " Database is ready!" break fi echo " Attempt $i/30 - waiting..." sleep 2 done
fi # ---- Step 6: Start Asterisk ----
echo "Starting Asterisk..."
echo "Command: $@" # If the command is 'asterisk', run as the asterisk user
if [ "$1" = "asterisk" ]; then exec "$@" -U asterisk -G asterisk
else exec "$@"
fi
#!/bin/bash
set -e # =============================================================================
# Asterisk Container Entrypoint
# - Processes config templates (envsubst)
# - Sets up ODBC configuration
# - Fixes permissions
# - Starts Asterisk with proper flags
# ============================================================================= echo "=== Asterisk Container Starting ==="
echo "Hostname: $(hostname)"
echo "Date: $(date -u '+%Y-%m-%d %H:%M:%S UTC')" # ---- Step 1: Process configuration templates ----
# Any file ending in .template gets processed with envsubst
# Environment variables like ${PJSIP_EXTERNAL_IP} are replaced with values
echo "Processing configuration templates..." TEMPLATE_DIR="/etc/asterisk/templates"
CONFIG_DIR="/etc/asterisk" if [ -d "$TEMPLATE_DIR" ]; then for template in "$TEMPLATE_DIR"/*.template; do [ -f "$template" ] || continue filename=$(basename "$template" .template) echo " Rendering: $filename" envsubst < "$template" > "$CONFIG_DIR/$filename" done
fi # ---- Step 2: Process ODBC configuration ----
if [ -f "/etc/odbc.ini.template" ]; then echo "Rendering ODBC configuration..." envsubst < /etc/odbc.ini.template > /etc/odbc.ini
fi # ---- Step 3: Set NAT/network variables ----
# Auto-detect public IP if not provided
if [ -z "$PJSIP_EXTERNAL_IP" ]; then echo "PJSIP_EXTERNAL_IP not set, auto-detecting..." PJSIP_EXTERNAL_IP=$(curl -s -4 --max-time 5 https://ifconfig.me || echo "") if [ -n "$PJSIP_EXTERNAL_IP" ]; then echo " Detected external IP: $PJSIP_EXTERNAL_IP" export PJSIP_EXTERNAL_IP else echo " WARNING: Could not detect external IP. NAT may not work correctly." fi
fi # Re-process templates now that PJSIP_EXTERNAL_IP might be set
if [ -d "$TEMPLATE_DIR" ]; then for template in "$TEMPLATE_DIR"/*.template; do [ -f "$template" ] || continue filename=$(basename "$template" .template) envsubst < "$template" > "$CONFIG_DIR/$filename" done
fi # ---- Step 4: Fix permissions ----
echo "Setting permissions..."
chown -R asterisk:asterisk /var/run/asterisk
chown -R asterisk:asterisk /var/log/asterisk
chown -R asterisk:asterisk /var/spool/asterisk
chown -R asterisk:asterisk /etc/asterisk
chown -R asterisk:asterisk /var/lib/asterisk # ---- Step 5: Wait for database ----
if [ -n "$DB_HOST" ]; then echo "Waiting for database at $DB_HOST:${DB_PORT:-3306}..." for i in $(seq 1 30); do if curl -sf "telnet://$DB_HOST:${DB_PORT:-3306}" --max-time 2 >/dev/null 2>&1 || \ bash -c "echo >/dev/tcp/$DB_HOST/${DB_PORT:-3306}" 2>/dev/null; then echo " Database is ready!" break fi echo " Attempt $i/30 - waiting..." sleep 2 done
fi # ---- Step 6: Start Asterisk ----
echo "Starting Asterisk..."
echo "Command: $@" # If the command is 'asterisk', run as the asterisk user
if [ "$1" = "asterisk" ]; then exec "$@" -U asterisk -G asterisk
else exec "$@"
fi
#!/bin/bash
set -e # =============================================================================
# Asterisk Container Entrypoint
# - Processes config templates (envsubst)
# - Sets up ODBC configuration
# - Fixes permissions
# - Starts Asterisk with proper flags
# ============================================================================= echo "=== Asterisk Container Starting ==="
echo "Hostname: $(hostname)"
echo "Date: $(date -u '+%Y-%m-%d %H:%M:%S UTC')" # ---- Step 1: Process configuration templates ----
# Any file ending in .template gets processed with envsubst
# Environment variables like ${PJSIP_EXTERNAL_IP} are replaced with values
echo "Processing configuration templates..." TEMPLATE_DIR="/etc/asterisk/templates"
CONFIG_DIR="/etc/asterisk" if [ -d "$TEMPLATE_DIR" ]; then for template in "$TEMPLATE_DIR"/*.template; do [ -f "$template" ] || continue filename=$(basename "$template" .template) echo " Rendering: $filename" envsubst < "$template" > "$CONFIG_DIR/$filename" done
fi # ---- Step 2: Process ODBC configuration ----
if [ -f "/etc/odbc.ini.template" ]; then echo "Rendering ODBC configuration..." envsubst < /etc/odbc.ini.template > /etc/odbc.ini
fi # ---- Step 3: Set NAT/network variables ----
# Auto-detect public IP if not provided
if [ -z "$PJSIP_EXTERNAL_IP" ]; then echo "PJSIP_EXTERNAL_IP not set, auto-detecting..." PJSIP_EXTERNAL_IP=$(curl -s -4 --max-time 5 https://ifconfig.me || echo "") if [ -n "$PJSIP_EXTERNAL_IP" ]; then echo " Detected external IP: $PJSIP_EXTERNAL_IP" export PJSIP_EXTERNAL_IP else echo " WARNING: Could not detect external IP. NAT may not work correctly." fi
fi # Re-process templates now that PJSIP_EXTERNAL_IP might be set
if [ -d "$TEMPLATE_DIR" ]; then for template in "$TEMPLATE_DIR"/*.template; do [ -f "$template" ] || continue filename=$(basename "$template" .template) envsubst < "$template" > "$CONFIG_DIR/$filename" done
fi # ---- Step 4: Fix permissions ----
echo "Setting permissions..."
chown -R asterisk:asterisk /var/run/asterisk
chown -R asterisk:asterisk /var/log/asterisk
chown -R asterisk:asterisk /var/spool/asterisk
chown -R asterisk:asterisk /etc/asterisk
chown -R asterisk:asterisk /var/lib/asterisk # ---- Step 5: Wait for database ----
if [ -n "$DB_HOST" ]; then echo "Waiting for database at $DB_HOST:${DB_PORT:-3306}..." for i in $(seq 1 30); do if curl -sf "telnet://$DB_HOST:${DB_PORT:-3306}" --max-time 2 >/dev/null 2>&1 || \ bash -c "echo >/dev/tcp/$DB_HOST/${DB_PORT:-3306}" 2>/dev/null; then echo " Database is ready!" break fi echo " Attempt $i/30 - waiting..." sleep 2 done
fi # ---- Step 6: Start Asterisk ----
echo "Starting Asterisk..."
echo "Command: $@" # If the command is 'asterisk', run as the asterisk user
if [ "$1" = "asterisk" ]; then exec "$@" -U asterisk -G asterisk
else exec "$@"
fi
cd /opt/asterisk-docker # Build the image (takes 10-20 minutes on first build)
docker build -t asterisk:21-custom ./asterisk/ # Verify the image size
docker images asterisk:21-custom
# REPOSITORY TAG SIZE
# asterisk 21-custom ~250MB # Quick smoke test
docker run --rm asterisk:21-custom asterisk -V
# Asterisk 21.7.0 # Test module loading
docker run --rm asterisk:21-custom asterisk -rx "module show like pjsip" 2>/dev/null | head -5
cd /opt/asterisk-docker # Build the image (takes 10-20 minutes on first build)
docker build -t asterisk:21-custom ./asterisk/ # Verify the image size
docker images asterisk:21-custom
# REPOSITORY TAG SIZE
# asterisk 21-custom ~250MB # Quick smoke test
docker run --rm asterisk:21-custom asterisk -V
# Asterisk 21.7.0 # Test module loading
docker run --rm asterisk:21-custom asterisk -rx "module show like pjsip" 2>/dev/null | head -5
cd /opt/asterisk-docker # Build the image (takes 10-20 minutes on first build)
docker build -t asterisk:21-custom ./asterisk/ # Verify the image size
docker images asterisk:21-custom
# REPOSITORY TAG SIZE
# asterisk 21-custom ~250MB # Quick smoke test
docker run --rm asterisk:21-custom asterisk -V
# Asterisk 21.7.0 # Test module loading
docker run --rm asterisk:21-custom asterisk -rx "module show like pjsip" 2>/dev/null | head -5
# Build with a different Asterisk version
docker build --build-arg ASTERISK_VERSION=21.8.0 -t asterisk:21.8 ./asterisk/ # Build with Asterisk 20 LTS instead
docker build --build-arg ASTERISK_VERSION=20.11.0 -t asterisk:20 ./asterisk/
# Build with a different Asterisk version
docker build --build-arg ASTERISK_VERSION=21.8.0 -t asterisk:21.8 ./asterisk/ # Build with Asterisk 20 LTS instead
docker build --build-arg ASTERISK_VERSION=20.11.0 -t asterisk:20 ./asterisk/
# Build with a different Asterisk version
docker build --build-arg ASTERISK_VERSION=21.8.0 -t asterisk:21.8 ./asterisk/ # Build with Asterisk 20 LTS instead
docker build --build-arg ASTERISK_VERSION=20.11.0 -t asterisk:20 ./asterisk/
# =============================================================================
# Asterisk Docker Stack - Environment Variables
# Copy this file to .env and customize for your deployment
# ============================================================================= # ---- General ----
COMPOSE_PROJECT_NAME=asterisk-stack
TZ=UTC # ---- Network / NAT ----
# Your server's public IP address (REQUIRED for SIP/RTP to work)
PJSIP_EXTERNAL_IP=YOUR_SERVER_IP
# Your domain name (for TLS certificates)
DOMAIN=YOUR_DOMAIN
# Contact email for Let's Encrypt
ACME_EMAIL=admin@YOUR_DOMAIN # ---- SIP Configuration ----
# Default SIP endpoint password (change this!)
SIP_DEFAULT_PASSWORD=Ch4ng3M3N0w!
# SIP realm for authentication
SIP_REALM=YOUR_DOMAIN # ---- RTP ----
RTP_PORT_START=10000
RTP_PORT_END=20000 # ---- Database ----
DB_HOST=mariadb
DB_PORT=3306
DB_NAME=asterisk
DB_USER=asterisk
DB_PASSWORD=Ast3r1sk_DB_2026!
DB_ROOT_PASSWORD=R00t_DB_S3cur3! # ---- Redis ----
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_PASSWORD=R3d1s_S3cur3_2026! # ---- ARI ----
ARI_USERNAME=ari_user
ARI_PASSWORD=Ar1_S3cur3_2026! # ---- AMI (Asterisk Manager Interface) ----
AMI_USERNAME=ami_admin
AMI_PASSWORD=Am1_S3cur3_2026! # ---- TURN Server ----
TURN_SECRET=T0rn_Sh4r3d_S3cr3t_2026!
TURN_REALM=YOUR_DOMAIN # ---- Nginx ----
NGINX_CLIENT_MAX_BODY=50M
# =============================================================================
# Asterisk Docker Stack - Environment Variables
# Copy this file to .env and customize for your deployment
# ============================================================================= # ---- General ----
COMPOSE_PROJECT_NAME=asterisk-stack
TZ=UTC # ---- Network / NAT ----
# Your server's public IP address (REQUIRED for SIP/RTP to work)
PJSIP_EXTERNAL_IP=YOUR_SERVER_IP
# Your domain name (for TLS certificates)
DOMAIN=YOUR_DOMAIN
# Contact email for Let's Encrypt
ACME_EMAIL=admin@YOUR_DOMAIN # ---- SIP Configuration ----
# Default SIP endpoint password (change this!)
SIP_DEFAULT_PASSWORD=Ch4ng3M3N0w!
# SIP realm for authentication
SIP_REALM=YOUR_DOMAIN # ---- RTP ----
RTP_PORT_START=10000
RTP_PORT_END=20000 # ---- Database ----
DB_HOST=mariadb
DB_PORT=3306
DB_NAME=asterisk
DB_USER=asterisk
DB_PASSWORD=Ast3r1sk_DB_2026!
DB_ROOT_PASSWORD=R00t_DB_S3cur3! # ---- Redis ----
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_PASSWORD=R3d1s_S3cur3_2026! # ---- ARI ----
ARI_USERNAME=ari_user
ARI_PASSWORD=Ar1_S3cur3_2026! # ---- AMI (Asterisk Manager Interface) ----
AMI_USERNAME=ami_admin
AMI_PASSWORD=Am1_S3cur3_2026! # ---- TURN Server ----
TURN_SECRET=T0rn_Sh4r3d_S3cr3t_2026!
TURN_REALM=YOUR_DOMAIN # ---- Nginx ----
NGINX_CLIENT_MAX_BODY=50M
# =============================================================================
# Asterisk Docker Stack - Environment Variables
# Copy this file to .env and customize for your deployment
# ============================================================================= # ---- General ----
COMPOSE_PROJECT_NAME=asterisk-stack
TZ=UTC # ---- Network / NAT ----
# Your server's public IP address (REQUIRED for SIP/RTP to work)
PJSIP_EXTERNAL_IP=YOUR_SERVER_IP
# Your domain name (for TLS certificates)
DOMAIN=YOUR_DOMAIN
# Contact email for Let's Encrypt
ACME_EMAIL=admin@YOUR_DOMAIN # ---- SIP Configuration ----
# Default SIP endpoint password (change this!)
SIP_DEFAULT_PASSWORD=Ch4ng3M3N0w!
# SIP realm for authentication
SIP_REALM=YOUR_DOMAIN # ---- RTP ----
RTP_PORT_START=10000
RTP_PORT_END=20000 # ---- Database ----
DB_HOST=mariadb
DB_PORT=3306
DB_NAME=asterisk
DB_USER=asterisk
DB_PASSWORD=Ast3r1sk_DB_2026!
DB_ROOT_PASSWORD=R00t_DB_S3cur3! # ---- Redis ----
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_PASSWORD=R3d1s_S3cur3_2026! # ---- ARI ----
ARI_USERNAME=ari_user
ARI_PASSWORD=Ar1_S3cur3_2026! # ---- AMI (Asterisk Manager Interface) ----
AMI_USERNAME=ami_admin
AMI_PASSWORD=Am1_S3cur3_2026! # ---- TURN Server ----
TURN_SECRET=T0rn_Sh4r3d_S3cr3t_2026!
TURN_REALM=YOUR_DOMAIN # ---- Nginx ----
NGINX_CLIENT_MAX_BODY=50M
# =============================================================================
# Asterisk Production Stack - Docker Compose
# =============================================================================
# Usage:
# docker compose up -d # Start all services
# docker compose logs -f # Follow all logs
# docker compose exec asterisk asterisk -rvvv # Asterisk CLI
# docker compose down # Stop all services
# ============================================================================= services: # --------------------------------------------------------------------------- # Asterisk PBX # --------------------------------------------------------------------------- asterisk: build: context: ./asterisk dockerfile: Dockerfile container_name: asterisk restart: unless-stopped # For production with real SIP trunks, host networking is often simplest. # See Section 7 for detailed discussion of networking modes. # Option A: Bridge networking (more isolated, more complex NAT config) networks: - asterisk-net ports: # SIP signaling - "5060:5060/udp" - "5060:5060/tcp" - "5061:5061/tcp" # AMI (restrict to localhost in production) - "127.0.0.1:5038:5038/tcp" # ARI HTTP (proxied through Nginx, but expose for internal access) - "127.0.0.1:8088:8088/tcp" # RTP media - this is a LARGE range # See Section 7 for why this is necessary and how to minimize it - "10000-20000:10000-20000/udp" # Option B: Host networking (uncomment below, comment out ports above) # network_mode: host environment: - TZ=${TZ:-UTC} - PJSIP_EXTERNAL_IP=${PJSIP_EXTERNAL_IP} - DOMAIN=${DOMAIN} - SIP_DEFAULT_PASSWORD=${SIP_DEFAULT_PASSWORD} - SIP_REALM=${SIP_REALM:-${DOMAIN}} - RTP_PORT_START=${RTP_PORT_START:-10000} - RTP_PORT_END=${RTP_PORT_END:-20000} - DB_HOST=${DB_HOST:-mariadb} - DB_PORT=${DB_PORT:-3306} - DB_NAME=${DB_NAME:-asterisk} - DB_USER=${DB_USER:-asterisk} - DB_PASSWORD=${DB_PASSWORD} - REDIS_HOST=${REDIS_HOST:-redis} - REDIS_PORT=${REDIS_PORT:-6379} - ARI_USERNAME=${ARI_USERNAME:-ari_user} - ARI_PASSWORD=${ARI_PASSWORD} - AMI_USERNAME=${AMI_USERNAME:-ami_admin} - AMI_PASSWORD=${AMI_PASSWORD} volumes: # Configuration templates (processed by entrypoint) - ./asterisk/configs:/etc/asterisk/templates:ro # Static configs (not templated) - ./asterisk/configs/modules.conf:/etc/asterisk/modules.conf:ro - ./asterisk/configs/rtp.conf:/etc/asterisk/rtp.conf:ro - ./asterisk/configs/cdr.conf:/etc/asterisk/cdr.conf:ro # ODBC configuration template - ./asterisk/configs/odbc.ini.template:/etc/odbc.ini.template:ro # Persistent data - recordings:/var/spool/asterisk/monitor - voicemail:/var/spool/asterisk/voicemail - asterisk-logs:/var/log/asterisk # Custom sounds - ./asterisk/sounds/custom:/var/lib/asterisk/sounds/custom:ro # TLS certificates (shared with Nginx/Certbot) - certs:/etc/asterisk/certs:ro depends_on: mariadb: condition: service_healthy redis: condition: service_healthy ulimits: nofile: soft: 65536 hard: 65536 core: soft: -1 hard: -1 deploy: resources: limits: cpus: '4' memory: 4G reservations: cpus: '1' memory: 512M healthcheck: test: ["CMD", "asterisk", "-rx", "core show version"] interval: 30s timeout: 5s start_period: 15s retries: 3 logging: driver: json-file options: max-size: "50m" max-file: "5" # --------------------------------------------------------------------------- # MariaDB - CDR, Realtime Config, Voicemail # --------------------------------------------------------------------------- mariadb: image: mariadb:11.4 container_name: asterisk-mariadb restart: unless-stopped networks: - asterisk-net # Do NOT expose port externally in production # Uncomment only for debugging: # ports: # - "127.0.0.1:3306:3306/tcp" environment: - MARIADB_ROOT_PASSWORD=${DB_ROOT_PASSWORD} - MARIADB_DATABASE=${DB_NAME:-asterisk} - MARIADB_USER=${DB_USER:-asterisk} - MARIADB_PASSWORD=${DB_PASSWORD} - TZ=${TZ:-UTC} volumes: - mariadb-data:/var/lib/mysql - ./mariadb/init:/docker-entrypoint-initdb.d:ro command: > --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci --max-connections=200 --innodb-buffer-pool-size=256M --innodb-log-file-size=64M --max-allowed-packet=64M --slow-query-log=1 --slow-query-log-file=/var/lib/mysql/slow.log --long-query-time=2 deploy: resources: limits: cpus: '2' memory: 1G reservations: memory: 256M healthcheck: test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] interval: 15s timeout: 5s start_period: 30s retries: 5 logging: driver: json-file options: max-size: "20m" max-file: "3" # --------------------------------------------------------------------------- # Redis - ARI State, Session Cache # --------------------------------------------------------------------------- redis: image: redis:7-alpine container_name: asterisk-redis restart: unless-stopped networks: - asterisk-net command: > redis-server --requirepass ${REDIS_PASSWORD} --maxmemory 128mb --maxmemory-policy allkeys-lru --appendonly yes --appendfsync everysec volumes: - redis-data:/data deploy: resources: limits: cpus: '0.5' memory: 256M healthcheck: test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"] interval: 15s timeout: 3s retries: 3 logging: driver: json-file options: max-size: "10m" max-file: "3" # --------------------------------------------------------------------------- # Nginx - Reverse Proxy, TLS Termination, WebSocket Proxy # --------------------------------------------------------------------------- nginx: image: nginx:1.27-alpine container_name: asterisk-nginx restart: unless-stopped networks: - asterisk-net ports: - "80:80/tcp" - "443:443/tcp" environment: - DOMAIN=${DOMAIN} volumes: - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro - ./nginx/conf.d:/etc/nginx/conf.d:ro - certs:/etc/nginx/certs:ro - certbot-webroot:/var/www/certbot:ro depends_on: - asterisk deploy: resources: limits: cpus: '1' memory: 256M healthcheck: test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost/health"] interval: 30s timeout: 5s retries: 3 logging: driver: json-file options: max-size: "20m" max-file: "3" # --------------------------------------------------------------------------- # Certbot - Let's Encrypt Certificate Management # --------------------------------------------------------------------------- certbot: image: certbot/certbot:latest container_name: asterisk-certbot restart: unless-stopped volumes: - certs:/etc/letsencrypt - certbot-webroot:/var/www/certbot # Check for renewal twice daily (standard recommendation) entrypoint: /bin/sh -c "trap exit TERM; while :; do certbot renew --quiet; sleep 12h & wait $${!}; done" depends_on: - nginx # --------------------------------------------------------------------------- # Coturn - TURN/STUN Server for WebRTC NAT Traversal # --------------------------------------------------------------------------- coturn: image: coturn/coturn:latest container_name: asterisk-coturn restart: unless-stopped network_mode: host volumes: - ./coturn/turnserver.conf:/etc/turnserver.conf:ro - certs:/etc/certs:ro command: ["-c", "/etc/turnserver.conf"] deploy: resources: limits: cpus: '1' memory: 512M logging: driver: json-file options: max-size: "20m" max-file: "3" # =============================================================================
# Networks
# =============================================================================
networks: asterisk-net: driver: bridge ipam: config: - subnet: 172.25.0.0/24 # =============================================================================
# Volumes
# =============================================================================
volumes: mariadb-data: driver: local redis-data: driver: local recordings: driver: local driver_opts: type: none device: /opt/asterisk-docker/data/recordings o: bind voicemail: driver: local driver_opts: type: none device: /opt/asterisk-docker/data/voicemail o: bind asterisk-logs: driver: local driver_opts: type: none device: /opt/asterisk-docker/data/logs o: bind certs: driver: local driver_opts: type: none device: /opt/asterisk-docker/data/certs o: bind certbot-webroot: driver: local
# =============================================================================
# Asterisk Production Stack - Docker Compose
# =============================================================================
# Usage:
# docker compose up -d # Start all services
# docker compose logs -f # Follow all logs
# docker compose exec asterisk asterisk -rvvv # Asterisk CLI
# docker compose down # Stop all services
# ============================================================================= services: # --------------------------------------------------------------------------- # Asterisk PBX # --------------------------------------------------------------------------- asterisk: build: context: ./asterisk dockerfile: Dockerfile container_name: asterisk restart: unless-stopped # For production with real SIP trunks, host networking is often simplest. # See Section 7 for detailed discussion of networking modes. # Option A: Bridge networking (more isolated, more complex NAT config) networks: - asterisk-net ports: # SIP signaling - "5060:5060/udp" - "5060:5060/tcp" - "5061:5061/tcp" # AMI (restrict to localhost in production) - "127.0.0.1:5038:5038/tcp" # ARI HTTP (proxied through Nginx, but expose for internal access) - "127.0.0.1:8088:8088/tcp" # RTP media - this is a LARGE range # See Section 7 for why this is necessary and how to minimize it - "10000-20000:10000-20000/udp" # Option B: Host networking (uncomment below, comment out ports above) # network_mode: host environment: - TZ=${TZ:-UTC} - PJSIP_EXTERNAL_IP=${PJSIP_EXTERNAL_IP} - DOMAIN=${DOMAIN} - SIP_DEFAULT_PASSWORD=${SIP_DEFAULT_PASSWORD} - SIP_REALM=${SIP_REALM:-${DOMAIN}} - RTP_PORT_START=${RTP_PORT_START:-10000} - RTP_PORT_END=${RTP_PORT_END:-20000} - DB_HOST=${DB_HOST:-mariadb} - DB_PORT=${DB_PORT:-3306} - DB_NAME=${DB_NAME:-asterisk} - DB_USER=${DB_USER:-asterisk} - DB_PASSWORD=${DB_PASSWORD} - REDIS_HOST=${REDIS_HOST:-redis} - REDIS_PORT=${REDIS_PORT:-6379} - ARI_USERNAME=${ARI_USERNAME:-ari_user} - ARI_PASSWORD=${ARI_PASSWORD} - AMI_USERNAME=${AMI_USERNAME:-ami_admin} - AMI_PASSWORD=${AMI_PASSWORD} volumes: # Configuration templates (processed by entrypoint) - ./asterisk/configs:/etc/asterisk/templates:ro # Static configs (not templated) - ./asterisk/configs/modules.conf:/etc/asterisk/modules.conf:ro - ./asterisk/configs/rtp.conf:/etc/asterisk/rtp.conf:ro - ./asterisk/configs/cdr.conf:/etc/asterisk/cdr.conf:ro # ODBC configuration template - ./asterisk/configs/odbc.ini.template:/etc/odbc.ini.template:ro # Persistent data - recordings:/var/spool/asterisk/monitor - voicemail:/var/spool/asterisk/voicemail - asterisk-logs:/var/log/asterisk # Custom sounds - ./asterisk/sounds/custom:/var/lib/asterisk/sounds/custom:ro # TLS certificates (shared with Nginx/Certbot) - certs:/etc/asterisk/certs:ro depends_on: mariadb: condition: service_healthy redis: condition: service_healthy ulimits: nofile: soft: 65536 hard: 65536 core: soft: -1 hard: -1 deploy: resources: limits: cpus: '4' memory: 4G reservations: cpus: '1' memory: 512M healthcheck: test: ["CMD", "asterisk", "-rx", "core show version"] interval: 30s timeout: 5s start_period: 15s retries: 3 logging: driver: json-file options: max-size: "50m" max-file: "5" # --------------------------------------------------------------------------- # MariaDB - CDR, Realtime Config, Voicemail # --------------------------------------------------------------------------- mariadb: image: mariadb:11.4 container_name: asterisk-mariadb restart: unless-stopped networks: - asterisk-net # Do NOT expose port externally in production # Uncomment only for debugging: # ports: # - "127.0.0.1:3306:3306/tcp" environment: - MARIADB_ROOT_PASSWORD=${DB_ROOT_PASSWORD} - MARIADB_DATABASE=${DB_NAME:-asterisk} - MARIADB_USER=${DB_USER:-asterisk} - MARIADB_PASSWORD=${DB_PASSWORD} - TZ=${TZ:-UTC} volumes: - mariadb-data:/var/lib/mysql - ./mariadb/init:/docker-entrypoint-initdb.d:ro command: > --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci --max-connections=200 --innodb-buffer-pool-size=256M --innodb-log-file-size=64M --max-allowed-packet=64M --slow-query-log=1 --slow-query-log-file=/var/lib/mysql/slow.log --long-query-time=2 deploy: resources: limits: cpus: '2' memory: 1G reservations: memory: 256M healthcheck: test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] interval: 15s timeout: 5s start_period: 30s retries: 5 logging: driver: json-file options: max-size: "20m" max-file: "3" # --------------------------------------------------------------------------- # Redis - ARI State, Session Cache # --------------------------------------------------------------------------- redis: image: redis:7-alpine container_name: asterisk-redis restart: unless-stopped networks: - asterisk-net command: > redis-server --requirepass ${REDIS_PASSWORD} --maxmemory 128mb --maxmemory-policy allkeys-lru --appendonly yes --appendfsync everysec volumes: - redis-data:/data deploy: resources: limits: cpus: '0.5' memory: 256M healthcheck: test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"] interval: 15s timeout: 3s retries: 3 logging: driver: json-file options: max-size: "10m" max-file: "3" # --------------------------------------------------------------------------- # Nginx - Reverse Proxy, TLS Termination, WebSocket Proxy # --------------------------------------------------------------------------- nginx: image: nginx:1.27-alpine container_name: asterisk-nginx restart: unless-stopped networks: - asterisk-net ports: - "80:80/tcp" - "443:443/tcp" environment: - DOMAIN=${DOMAIN} volumes: - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro - ./nginx/conf.d:/etc/nginx/conf.d:ro - certs:/etc/nginx/certs:ro - certbot-webroot:/var/www/certbot:ro depends_on: - asterisk deploy: resources: limits: cpus: '1' memory: 256M healthcheck: test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost/health"] interval: 30s timeout: 5s retries: 3 logging: driver: json-file options: max-size: "20m" max-file: "3" # --------------------------------------------------------------------------- # Certbot - Let's Encrypt Certificate Management # --------------------------------------------------------------------------- certbot: image: certbot/certbot:latest container_name: asterisk-certbot restart: unless-stopped volumes: - certs:/etc/letsencrypt - certbot-webroot:/var/www/certbot # Check for renewal twice daily (standard recommendation) entrypoint: /bin/sh -c "trap exit TERM; while :; do certbot renew --quiet; sleep 12h & wait $${!}; done" depends_on: - nginx # --------------------------------------------------------------------------- # Coturn - TURN/STUN Server for WebRTC NAT Traversal # --------------------------------------------------------------------------- coturn: image: coturn/coturn:latest container_name: asterisk-coturn restart: unless-stopped network_mode: host volumes: - ./coturn/turnserver.conf:/etc/turnserver.conf:ro - certs:/etc/certs:ro command: ["-c", "/etc/turnserver.conf"] deploy: resources: limits: cpus: '1' memory: 512M logging: driver: json-file options: max-size: "20m" max-file: "3" # =============================================================================
# Networks
# =============================================================================
networks: asterisk-net: driver: bridge ipam: config: - subnet: 172.25.0.0/24 # =============================================================================
# Volumes
# =============================================================================
volumes: mariadb-data: driver: local redis-data: driver: local recordings: driver: local driver_opts: type: none device: /opt/asterisk-docker/data/recordings o: bind voicemail: driver: local driver_opts: type: none device: /opt/asterisk-docker/data/voicemail o: bind asterisk-logs: driver: local driver_opts: type: none device: /opt/asterisk-docker/data/logs o: bind certs: driver: local driver_opts: type: none device: /opt/asterisk-docker/data/certs o: bind certbot-webroot: driver: local
# =============================================================================
# Asterisk Production Stack - Docker Compose
# =============================================================================
# Usage:
# docker compose up -d # Start all services
# docker compose logs -f # Follow all logs
# docker compose exec asterisk asterisk -rvvv # Asterisk CLI
# docker compose down # Stop all services
# ============================================================================= services: # --------------------------------------------------------------------------- # Asterisk PBX # --------------------------------------------------------------------------- asterisk: build: context: ./asterisk dockerfile: Dockerfile container_name: asterisk restart: unless-stopped # For production with real SIP trunks, host networking is often simplest. # See Section 7 for detailed discussion of networking modes. # Option A: Bridge networking (more isolated, more complex NAT config) networks: - asterisk-net ports: # SIP signaling - "5060:5060/udp" - "5060:5060/tcp" - "5061:5061/tcp" # AMI (restrict to localhost in production) - "127.0.0.1:5038:5038/tcp" # ARI HTTP (proxied through Nginx, but expose for internal access) - "127.0.0.1:8088:8088/tcp" # RTP media - this is a LARGE range # See Section 7 for why this is necessary and how to minimize it - "10000-20000:10000-20000/udp" # Option B: Host networking (uncomment below, comment out ports above) # network_mode: host environment: - TZ=${TZ:-UTC} - PJSIP_EXTERNAL_IP=${PJSIP_EXTERNAL_IP} - DOMAIN=${DOMAIN} - SIP_DEFAULT_PASSWORD=${SIP_DEFAULT_PASSWORD} - SIP_REALM=${SIP_REALM:-${DOMAIN}} - RTP_PORT_START=${RTP_PORT_START:-10000} - RTP_PORT_END=${RTP_PORT_END:-20000} - DB_HOST=${DB_HOST:-mariadb} - DB_PORT=${DB_PORT:-3306} - DB_NAME=${DB_NAME:-asterisk} - DB_USER=${DB_USER:-asterisk} - DB_PASSWORD=${DB_PASSWORD} - REDIS_HOST=${REDIS_HOST:-redis} - REDIS_PORT=${REDIS_PORT:-6379} - ARI_USERNAME=${ARI_USERNAME:-ari_user} - ARI_PASSWORD=${ARI_PASSWORD} - AMI_USERNAME=${AMI_USERNAME:-ami_admin} - AMI_PASSWORD=${AMI_PASSWORD} volumes: # Configuration templates (processed by entrypoint) - ./asterisk/configs:/etc/asterisk/templates:ro # Static configs (not templated) - ./asterisk/configs/modules.conf:/etc/asterisk/modules.conf:ro - ./asterisk/configs/rtp.conf:/etc/asterisk/rtp.conf:ro - ./asterisk/configs/cdr.conf:/etc/asterisk/cdr.conf:ro # ODBC configuration template - ./asterisk/configs/odbc.ini.template:/etc/odbc.ini.template:ro # Persistent data - recordings:/var/spool/asterisk/monitor - voicemail:/var/spool/asterisk/voicemail - asterisk-logs:/var/log/asterisk # Custom sounds - ./asterisk/sounds/custom:/var/lib/asterisk/sounds/custom:ro # TLS certificates (shared with Nginx/Certbot) - certs:/etc/asterisk/certs:ro depends_on: mariadb: condition: service_healthy redis: condition: service_healthy ulimits: nofile: soft: 65536 hard: 65536 core: soft: -1 hard: -1 deploy: resources: limits: cpus: '4' memory: 4G reservations: cpus: '1' memory: 512M healthcheck: test: ["CMD", "asterisk", "-rx", "core show version"] interval: 30s timeout: 5s start_period: 15s retries: 3 logging: driver: json-file options: max-size: "50m" max-file: "5" # --------------------------------------------------------------------------- # MariaDB - CDR, Realtime Config, Voicemail # --------------------------------------------------------------------------- mariadb: image: mariadb:11.4 container_name: asterisk-mariadb restart: unless-stopped networks: - asterisk-net # Do NOT expose port externally in production # Uncomment only for debugging: # ports: # - "127.0.0.1:3306:3306/tcp" environment: - MARIADB_ROOT_PASSWORD=${DB_ROOT_PASSWORD} - MARIADB_DATABASE=${DB_NAME:-asterisk} - MARIADB_USER=${DB_USER:-asterisk} - MARIADB_PASSWORD=${DB_PASSWORD} - TZ=${TZ:-UTC} volumes: - mariadb-data:/var/lib/mysql - ./mariadb/init:/docker-entrypoint-initdb.d:ro command: > --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci --max-connections=200 --innodb-buffer-pool-size=256M --innodb-log-file-size=64M --max-allowed-packet=64M --slow-query-log=1 --slow-query-log-file=/var/lib/mysql/slow.log --long-query-time=2 deploy: resources: limits: cpus: '2' memory: 1G reservations: memory: 256M healthcheck: test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] interval: 15s timeout: 5s start_period: 30s retries: 5 logging: driver: json-file options: max-size: "20m" max-file: "3" # --------------------------------------------------------------------------- # Redis - ARI State, Session Cache # --------------------------------------------------------------------------- redis: image: redis:7-alpine container_name: asterisk-redis restart: unless-stopped networks: - asterisk-net command: > redis-server --requirepass ${REDIS_PASSWORD} --maxmemory 128mb --maxmemory-policy allkeys-lru --appendonly yes --appendfsync everysec volumes: - redis-data:/data deploy: resources: limits: cpus: '0.5' memory: 256M healthcheck: test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"] interval: 15s timeout: 3s retries: 3 logging: driver: json-file options: max-size: "10m" max-file: "3" # --------------------------------------------------------------------------- # Nginx - Reverse Proxy, TLS Termination, WebSocket Proxy # --------------------------------------------------------------------------- nginx: image: nginx:1.27-alpine container_name: asterisk-nginx restart: unless-stopped networks: - asterisk-net ports: - "80:80/tcp" - "443:443/tcp" environment: - DOMAIN=${DOMAIN} volumes: - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro - ./nginx/conf.d:/etc/nginx/conf.d:ro - certs:/etc/nginx/certs:ro - certbot-webroot:/var/www/certbot:ro depends_on: - asterisk deploy: resources: limits: cpus: '1' memory: 256M healthcheck: test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost/health"] interval: 30s timeout: 5s retries: 3 logging: driver: json-file options: max-size: "20m" max-file: "3" # --------------------------------------------------------------------------- # Certbot - Let's Encrypt Certificate Management # --------------------------------------------------------------------------- certbot: image: certbot/certbot:latest container_name: asterisk-certbot restart: unless-stopped volumes: - certs:/etc/letsencrypt - certbot-webroot:/var/www/certbot # Check for renewal twice daily (standard recommendation) entrypoint: /bin/sh -c "trap exit TERM; while :; do certbot renew --quiet; sleep 12h & wait $${!}; done" depends_on: - nginx # --------------------------------------------------------------------------- # Coturn - TURN/STUN Server for WebRTC NAT Traversal # --------------------------------------------------------------------------- coturn: image: coturn/coturn:latest container_name: asterisk-coturn restart: unless-stopped network_mode: host volumes: - ./coturn/turnserver.conf:/etc/turnserver.conf:ro - certs:/etc/certs:ro command: ["-c", "/etc/turnserver.conf"] deploy: resources: limits: cpus: '1' memory: 512M logging: driver: json-file options: max-size: "20m" max-file: "3" # =============================================================================
# Networks
# =============================================================================
networks: asterisk-net: driver: bridge ipam: config: - subnet: 172.25.0.0/24 # =============================================================================
# Volumes
# =============================================================================
volumes: mariadb-data: driver: local redis-data: driver: local recordings: driver: local driver_opts: type: none device: /opt/asterisk-docker/data/recordings o: bind voicemail: driver: local driver_opts: type: none device: /opt/asterisk-docker/data/voicemail o: bind asterisk-logs: driver: local driver_opts: type: none device: /opt/asterisk-docker/data/logs o: bind certs: driver: local driver_opts: type: none device: /opt/asterisk-docker/data/certs o: bind certbot-webroot: driver: local
cd /opt/asterisk-docker # Create bind-mount directories
mkdir -p data/{recordings,voicemail,logs,certs} # Build and start everything
docker compose up -d --build # Watch logs during startup
docker compose logs -f asterisk # Verify all containers are healthy
docker compose ps # Access Asterisk CLI
docker compose exec asterisk asterisk -rvvv
cd /opt/asterisk-docker # Create bind-mount directories
mkdir -p data/{recordings,voicemail,logs,certs} # Build and start everything
docker compose up -d --build # Watch logs during startup
docker compose logs -f asterisk # Verify all containers are healthy
docker compose ps # Access Asterisk CLI
docker compose exec asterisk asterisk -rvvv
cd /opt/asterisk-docker # Create bind-mount directories
mkdir -p data/{recordings,voicemail,logs,certs} # Build and start everything
docker compose up -d --build # Watch logs during startup
docker compose logs -f asterisk # Verify all containers are healthy
docker compose ps # Access Asterisk CLI
docker compose exec asterisk asterisk -rvvv
; =============================================================================
; PJSIP Configuration - Containerized Asterisk
; Variables like ${VARIABLE} are replaced by entrypoint.sh at container start
; ============================================================================= ; ---- Global Settings ----
[global]
type = global
max_forwards = 70
user_agent = Asterisk PBX
default_outbound_endpoint = default-endpoint
keep_alive_interval = 90 ; ---- System Settings ----
[system]
type = system
timer_t1 = 500
timer_b = 32000
compact_headers = no ; =============================================================================
; Transports
; ============================================================================= ; ---- UDP Transport ----
[transport-udp]
type = transport
protocol = udp
bind = 0.0.0.0:5060
; NAT settings - critical for containerized Asterisk
external_media_address = ${PJSIP_EXTERNAL_IP}
external_signaling_address = ${PJSIP_EXTERNAL_IP}
local_net = 172.25.0.0/24
local_net = 10.0.0.0/8
local_net = 172.16.0.0/12
local_net = 192.168.0.0/16
local_net = 127.0.0.0/8 ; ---- TCP Transport ----
[transport-tcp]
type = transport
protocol = tcp
bind = 0.0.0.0:5060
external_media_address = ${PJSIP_EXTERNAL_IP}
external_signaling_address = ${PJSIP_EXTERNAL_IP}
local_net = 172.25.0.0/24
local_net = 10.0.0.0/8
local_net = 172.16.0.0/12
local_net = 192.168.0.0/16
local_net = 127.0.0.0/8 ; ---- TLS Transport ----
[transport-tls]
type = transport
protocol = tls
bind = 0.0.0.0:5061
external_media_address = ${PJSIP_EXTERNAL_IP}
external_signaling_address = ${PJSIP_EXTERNAL_IP}
local_net = 172.25.0.0/24
local_net = 10.0.0.0/8
local_net = 172.16.0.0/12
local_net = 192.168.0.0/16
local_net = 127.0.0.0/8
cert_file = /etc/asterisk/certs/live/${DOMAIN}/fullchain.pem
priv_key_file = /etc/asterisk/certs/live/${DOMAIN}/privkey.pem
method = tlsv1_2
; cipher = ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256 ; ---- WebSocket Transport (for WebRTC) ----
[transport-wss]
type = transport
protocol = wss
bind = 0.0.0.0:8089
external_media_address = ${PJSIP_EXTERNAL_IP}
external_signaling_address = ${PJSIP_EXTERNAL_IP}
local_net = 172.25.0.0/24
local_net = 10.0.0.0/8
local_net = 172.16.0.0/12
local_net = 192.168.0.0/16
local_net = 127.0.0.0/8
cert_file = /etc/asterisk/certs/live/${DOMAIN}/fullchain.pem
priv_key_file = /etc/asterisk/certs/live/${DOMAIN}/privkey.pem ; =============================================================================
; Endpoint Templates
; ============================================================================= ; ---- Template for standard SIP phones ----
[endpoint-internal](!)
type = endpoint
context = from-internal
dtmf_mode = rfc4733
disallow = all
allow = opus,g722,ulaw,alaw
direct_media = no
rtp_symmetric = yes
force_rport = yes
rewrite_contact = yes
ice_support = no
media_encryption = no
trust_id_inbound = yes
send_rpid = yes
send_pai = yes
callerid_privacy = allowed_not_screened
; One-way audio fix for NAT
media_address = ${PJSIP_EXTERNAL_IP} ; ---- Template for WebRTC endpoints ----
[endpoint-webrtc](!)
type = endpoint
context = from-internal
dtmf_mode = rfc4733
disallow = all
allow = opus,ulaw
direct_media = no
rtp_symmetric = yes
force_rport = yes
rewrite_contact = yes
ice_support = yes
media_encryption = dtls
dtls_auto_generate_cert = yes
dtls_verify = fingerprint
dtls_setup = actpass
media_use_received_transport = yes
trust_id_inbound = yes
webrtc = yes ; ---- Template for SIP trunks ----
[endpoint-trunk](!)
type = endpoint
context = from-trunk
dtmf_mode = rfc4733
disallow = all
allow = g722,ulaw,alaw
direct_media = no
rtp_symmetric = yes
force_rport = yes
rewrite_contact = yes
send_rpid = yes
send_pai = yes
media_address = ${PJSIP_EXTERNAL_IP}
t38_udptl = yes
t38_udptl_ec = redundancy ; =============================================================================
; Sample Endpoints
; ============================================================================= ; ---- Extension 1001 (SIP Phone) ----
[1001](endpoint-internal)
auth = 1001-auth
aors = 1001-aor
callerid = "Reception" <1001>
mailboxes = 1001@default
call_group = 1
pickup_group = 1 [1001-auth]
type = auth
auth_type = userpass
username = 1001
password = ${SIP_DEFAULT_PASSWORD}
realm = ${SIP_REALM} [1001-aor]
type = aor
max_contacts = 3
remove_existing = yes
qualify_frequency = 60
qualify_timeout = 5
minimum_expiration = 120
default_expiration = 300 ; ---- Extension 1002 (SIP Phone) ----
[1002](endpoint-internal)
auth = 1002-auth
aors = 1002-aor
callerid = "Sales" <1002>
mailboxes = 1002@default
call_group = 1
pickup_group = 1 [1002-auth]
type = auth
auth_type = userpass
username = 1002
password = ${SIP_DEFAULT_PASSWORD}
realm = ${SIP_REALM} [1002-aor]
type = aor
max_contacts = 3
remove_existing = yes
qualify_frequency = 60
qualify_timeout = 5 ; ---- Extension 1010 (WebRTC Softphone) ----
[1010](endpoint-webrtc)
auth = 1010-auth
aors = 1010-aor
callerid = "Web Phone" <1010>
mailboxes = 1010@default [1010-auth]
type = auth
auth_type = userpass
username = 1010
password = ${SIP_DEFAULT_PASSWORD}
realm = ${SIP_REALM} [1010-aor]
type = aor
max_contacts = 5
remove_existing = yes
qualify_frequency = 30 ; ---- Sample SIP Trunk ----
; Uncomment and configure for your SIP provider
; [my-trunk](endpoint-trunk)
; outbound_auth = my-trunk-auth
; aors = my-trunk-aor
; from_domain = sip.provider.example.com
; from_user = your_account_id
;
; [my-trunk-auth]
; type = auth
; auth_type = userpass
; username = your_account_id
; password = your_trunk_password
;
; [my-trunk-aor]
; type = aor
; contact = sip:sip.provider.example.com
; qualify_frequency = 60
;
; [my-trunk-identify]
; type = identify
; endpoint = my-trunk
; match = sip.provider.example.com ; ---- Default Endpoint (catch-all for unmatched) ----
[default-endpoint]
type = endpoint
context = default
disallow = all
allow = ulaw
transport = transport-udp
; =============================================================================
; PJSIP Configuration - Containerized Asterisk
; Variables like ${VARIABLE} are replaced by entrypoint.sh at container start
; ============================================================================= ; ---- Global Settings ----
[global]
type = global
max_forwards = 70
user_agent = Asterisk PBX
default_outbound_endpoint = default-endpoint
keep_alive_interval = 90 ; ---- System Settings ----
[system]
type = system
timer_t1 = 500
timer_b = 32000
compact_headers = no ; =============================================================================
; Transports
; ============================================================================= ; ---- UDP Transport ----
[transport-udp]
type = transport
protocol = udp
bind = 0.0.0.0:5060
; NAT settings - critical for containerized Asterisk
external_media_address = ${PJSIP_EXTERNAL_IP}
external_signaling_address = ${PJSIP_EXTERNAL_IP}
local_net = 172.25.0.0/24
local_net = 10.0.0.0/8
local_net = 172.16.0.0/12
local_net = 192.168.0.0/16
local_net = 127.0.0.0/8 ; ---- TCP Transport ----
[transport-tcp]
type = transport
protocol = tcp
bind = 0.0.0.0:5060
external_media_address = ${PJSIP_EXTERNAL_IP}
external_signaling_address = ${PJSIP_EXTERNAL_IP}
local_net = 172.25.0.0/24
local_net = 10.0.0.0/8
local_net = 172.16.0.0/12
local_net = 192.168.0.0/16
local_net = 127.0.0.0/8 ; ---- TLS Transport ----
[transport-tls]
type = transport
protocol = tls
bind = 0.0.0.0:5061
external_media_address = ${PJSIP_EXTERNAL_IP}
external_signaling_address = ${PJSIP_EXTERNAL_IP}
local_net = 172.25.0.0/24
local_net = 10.0.0.0/8
local_net = 172.16.0.0/12
local_net = 192.168.0.0/16
local_net = 127.0.0.0/8
cert_file = /etc/asterisk/certs/live/${DOMAIN}/fullchain.pem
priv_key_file = /etc/asterisk/certs/live/${DOMAIN}/privkey.pem
method = tlsv1_2
; cipher = ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256 ; ---- WebSocket Transport (for WebRTC) ----
[transport-wss]
type = transport
protocol = wss
bind = 0.0.0.0:8089
external_media_address = ${PJSIP_EXTERNAL_IP}
external_signaling_address = ${PJSIP_EXTERNAL_IP}
local_net = 172.25.0.0/24
local_net = 10.0.0.0/8
local_net = 172.16.0.0/12
local_net = 192.168.0.0/16
local_net = 127.0.0.0/8
cert_file = /etc/asterisk/certs/live/${DOMAIN}/fullchain.pem
priv_key_file = /etc/asterisk/certs/live/${DOMAIN}/privkey.pem ; =============================================================================
; Endpoint Templates
; ============================================================================= ; ---- Template for standard SIP phones ----
[endpoint-internal](!)
type = endpoint
context = from-internal
dtmf_mode = rfc4733
disallow = all
allow = opus,g722,ulaw,alaw
direct_media = no
rtp_symmetric = yes
force_rport = yes
rewrite_contact = yes
ice_support = no
media_encryption = no
trust_id_inbound = yes
send_rpid = yes
send_pai = yes
callerid_privacy = allowed_not_screened
; One-way audio fix for NAT
media_address = ${PJSIP_EXTERNAL_IP} ; ---- Template for WebRTC endpoints ----
[endpoint-webrtc](!)
type = endpoint
context = from-internal
dtmf_mode = rfc4733
disallow = all
allow = opus,ulaw
direct_media = no
rtp_symmetric = yes
force_rport = yes
rewrite_contact = yes
ice_support = yes
media_encryption = dtls
dtls_auto_generate_cert = yes
dtls_verify = fingerprint
dtls_setup = actpass
media_use_received_transport = yes
trust_id_inbound = yes
webrtc = yes ; ---- Template for SIP trunks ----
[endpoint-trunk](!)
type = endpoint
context = from-trunk
dtmf_mode = rfc4733
disallow = all
allow = g722,ulaw,alaw
direct_media = no
rtp_symmetric = yes
force_rport = yes
rewrite_contact = yes
send_rpid = yes
send_pai = yes
media_address = ${PJSIP_EXTERNAL_IP}
t38_udptl = yes
t38_udptl_ec = redundancy ; =============================================================================
; Sample Endpoints
; ============================================================================= ; ---- Extension 1001 (SIP Phone) ----
[1001](endpoint-internal)
auth = 1001-auth
aors = 1001-aor
callerid = "Reception" <1001>
mailboxes = 1001@default
call_group = 1
pickup_group = 1 [1001-auth]
type = auth
auth_type = userpass
username = 1001
password = ${SIP_DEFAULT_PASSWORD}
realm = ${SIP_REALM} [1001-aor]
type = aor
max_contacts = 3
remove_existing = yes
qualify_frequency = 60
qualify_timeout = 5
minimum_expiration = 120
default_expiration = 300 ; ---- Extension 1002 (SIP Phone) ----
[1002](endpoint-internal)
auth = 1002-auth
aors = 1002-aor
callerid = "Sales" <1002>
mailboxes = 1002@default
call_group = 1
pickup_group = 1 [1002-auth]
type = auth
auth_type = userpass
username = 1002
password = ${SIP_DEFAULT_PASSWORD}
realm = ${SIP_REALM} [1002-aor]
type = aor
max_contacts = 3
remove_existing = yes
qualify_frequency = 60
qualify_timeout = 5 ; ---- Extension 1010 (WebRTC Softphone) ----
[1010](endpoint-webrtc)
auth = 1010-auth
aors = 1010-aor
callerid = "Web Phone" <1010>
mailboxes = 1010@default [1010-auth]
type = auth
auth_type = userpass
username = 1010
password = ${SIP_DEFAULT_PASSWORD}
realm = ${SIP_REALM} [1010-aor]
type = aor
max_contacts = 5
remove_existing = yes
qualify_frequency = 30 ; ---- Sample SIP Trunk ----
; Uncomment and configure for your SIP provider
; [my-trunk](endpoint-trunk)
; outbound_auth = my-trunk-auth
; aors = my-trunk-aor
; from_domain = sip.provider.example.com
; from_user = your_account_id
;
; [my-trunk-auth]
; type = auth
; auth_type = userpass
; username = your_account_id
; password = your_trunk_password
;
; [my-trunk-aor]
; type = aor
; contact = sip:sip.provider.example.com
; qualify_frequency = 60
;
; [my-trunk-identify]
; type = identify
; endpoint = my-trunk
; match = sip.provider.example.com ; ---- Default Endpoint (catch-all for unmatched) ----
[default-endpoint]
type = endpoint
context = default
disallow = all
allow = ulaw
transport = transport-udp
; =============================================================================
; PJSIP Configuration - Containerized Asterisk
; Variables like ${VARIABLE} are replaced by entrypoint.sh at container start
; ============================================================================= ; ---- Global Settings ----
[global]
type = global
max_forwards = 70
user_agent = Asterisk PBX
default_outbound_endpoint = default-endpoint
keep_alive_interval = 90 ; ---- System Settings ----
[system]
type = system
timer_t1 = 500
timer_b = 32000
compact_headers = no ; =============================================================================
; Transports
; ============================================================================= ; ---- UDP Transport ----
[transport-udp]
type = transport
protocol = udp
bind = 0.0.0.0:5060
; NAT settings - critical for containerized Asterisk
external_media_address = ${PJSIP_EXTERNAL_IP}
external_signaling_address = ${PJSIP_EXTERNAL_IP}
local_net = 172.25.0.0/24
local_net = 10.0.0.0/8
local_net = 172.16.0.0/12
local_net = 192.168.0.0/16
local_net = 127.0.0.0/8 ; ---- TCP Transport ----
[transport-tcp]
type = transport
protocol = tcp
bind = 0.0.0.0:5060
external_media_address = ${PJSIP_EXTERNAL_IP}
external_signaling_address = ${PJSIP_EXTERNAL_IP}
local_net = 172.25.0.0/24
local_net = 10.0.0.0/8
local_net = 172.16.0.0/12
local_net = 192.168.0.0/16
local_net = 127.0.0.0/8 ; ---- TLS Transport ----
[transport-tls]
type = transport
protocol = tls
bind = 0.0.0.0:5061
external_media_address = ${PJSIP_EXTERNAL_IP}
external_signaling_address = ${PJSIP_EXTERNAL_IP}
local_net = 172.25.0.0/24
local_net = 10.0.0.0/8
local_net = 172.16.0.0/12
local_net = 192.168.0.0/16
local_net = 127.0.0.0/8
cert_file = /etc/asterisk/certs/live/${DOMAIN}/fullchain.pem
priv_key_file = /etc/asterisk/certs/live/${DOMAIN}/privkey.pem
method = tlsv1_2
; cipher = ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256 ; ---- WebSocket Transport (for WebRTC) ----
[transport-wss]
type = transport
protocol = wss
bind = 0.0.0.0:8089
external_media_address = ${PJSIP_EXTERNAL_IP}
external_signaling_address = ${PJSIP_EXTERNAL_IP}
local_net = 172.25.0.0/24
local_net = 10.0.0.0/8
local_net = 172.16.0.0/12
local_net = 192.168.0.0/16
local_net = 127.0.0.0/8
cert_file = /etc/asterisk/certs/live/${DOMAIN}/fullchain.pem
priv_key_file = /etc/asterisk/certs/live/${DOMAIN}/privkey.pem ; =============================================================================
; Endpoint Templates
; ============================================================================= ; ---- Template for standard SIP phones ----
[endpoint-internal](!)
type = endpoint
context = from-internal
dtmf_mode = rfc4733
disallow = all
allow = opus,g722,ulaw,alaw
direct_media = no
rtp_symmetric = yes
force_rport = yes
rewrite_contact = yes
ice_support = no
media_encryption = no
trust_id_inbound = yes
send_rpid = yes
send_pai = yes
callerid_privacy = allowed_not_screened
; One-way audio fix for NAT
media_address = ${PJSIP_EXTERNAL_IP} ; ---- Template for WebRTC endpoints ----
[endpoint-webrtc](!)
type = endpoint
context = from-internal
dtmf_mode = rfc4733
disallow = all
allow = opus,ulaw
direct_media = no
rtp_symmetric = yes
force_rport = yes
rewrite_contact = yes
ice_support = yes
media_encryption = dtls
dtls_auto_generate_cert = yes
dtls_verify = fingerprint
dtls_setup = actpass
media_use_received_transport = yes
trust_id_inbound = yes
webrtc = yes ; ---- Template for SIP trunks ----
[endpoint-trunk](!)
type = endpoint
context = from-trunk
dtmf_mode = rfc4733
disallow = all
allow = g722,ulaw,alaw
direct_media = no
rtp_symmetric = yes
force_rport = yes
rewrite_contact = yes
send_rpid = yes
send_pai = yes
media_address = ${PJSIP_EXTERNAL_IP}
t38_udptl = yes
t38_udptl_ec = redundancy ; =============================================================================
; Sample Endpoints
; ============================================================================= ; ---- Extension 1001 (SIP Phone) ----
[1001](endpoint-internal)
auth = 1001-auth
aors = 1001-aor
callerid = "Reception" <1001>
mailboxes = 1001@default
call_group = 1
pickup_group = 1 [1001-auth]
type = auth
auth_type = userpass
username = 1001
password = ${SIP_DEFAULT_PASSWORD}
realm = ${SIP_REALM} [1001-aor]
type = aor
max_contacts = 3
remove_existing = yes
qualify_frequency = 60
qualify_timeout = 5
minimum_expiration = 120
default_expiration = 300 ; ---- Extension 1002 (SIP Phone) ----
[1002](endpoint-internal)
auth = 1002-auth
aors = 1002-aor
callerid = "Sales" <1002>
mailboxes = 1002@default
call_group = 1
pickup_group = 1 [1002-auth]
type = auth
auth_type = userpass
username = 1002
password = ${SIP_DEFAULT_PASSWORD}
realm = ${SIP_REALM} [1002-aor]
type = aor
max_contacts = 3
remove_existing = yes
qualify_frequency = 60
qualify_timeout = 5 ; ---- Extension 1010 (WebRTC Softphone) ----
[1010](endpoint-webrtc)
auth = 1010-auth
aors = 1010-aor
callerid = "Web Phone" <1010>
mailboxes = 1010@default [1010-auth]
type = auth
auth_type = userpass
username = 1010
password = ${SIP_DEFAULT_PASSWORD}
realm = ${SIP_REALM} [1010-aor]
type = aor
max_contacts = 5
remove_existing = yes
qualify_frequency = 30 ; ---- Sample SIP Trunk ----
; Uncomment and configure for your SIP provider
; [my-trunk](endpoint-trunk)
; outbound_auth = my-trunk-auth
; aors = my-trunk-aor
; from_domain = sip.provider.example.com
; from_user = your_account_id
;
; [my-trunk-auth]
; type = auth
; auth_type = userpass
; username = your_account_id
; password = your_trunk_password
;
; [my-trunk-aor]
; type = aor
; contact = sip:sip.provider.example.com
; qualify_frequency = 60
;
; [my-trunk-identify]
; type = identify
; endpoint = my-trunk
; match = sip.provider.example.com ; ---- Default Endpoint (catch-all for unmatched) ----
[default-endpoint]
type = endpoint
context = default
disallow = all
allow = ulaw
transport = transport-udp
; =============================================================================
; Dialplan - Containerized Asterisk
; ============================================================================= [general]
static = yes
writeprotect = no
clearglobalvars = no [globals]
; Voicemail context
VM_CONTEXT = default
; Ring time before voicemail (seconds)
RING_TIMEOUT = 25
; Company name for auto-attendant
COMPANY_NAME = "Acme Corp"
; ARI application name
ARI_APP = autoattendant
; External caller ID for outbound calls
TRUNK_CID = "Main Line" <+15551234567> ; =============================================================================
; Internal Calls Context
; =============================================================================
[from-internal]
; ---- Direct extension dialing (1001-1099) ----
exten => _10XX,1,NoOp(Internal call to ${EXTEN}) same => n,Set(CALLERID(name)=${CALLERID(name)}) same => n,Dial(PJSIP/${EXTEN},${RING_TIMEOUT},tTkK) same => n,GotoIf($["${DIALSTATUS}" = "BUSY"]?busy:unavail) same => n(busy),VoiceMail(${EXTEN}@${VM_CONTEXT},b) same => n,Hangup() same => n(unavail),VoiceMail(${EXTEN}@${VM_CONTEXT},u) same => n,Hangup() ; ---- Ring group: Sales (ring all sales extensions) ----
exten => 2001,1,NoOp(Ring Group: Sales) same => n,Set(CALLERID(name)=${CALLERID(name)} [Sales]) same => n,Dial(PJSIP/1001&PJSIP/1002&PJSIP/1003,${RING_TIMEOUT},tTkK) same => n,VoiceMail(2001@${VM_CONTEXT},u) same => n,Hangup() ; ---- Ring group: Support ----
exten => 2002,1,NoOp(Ring Group: Support) same => n,Set(CALLERID(name)=${CALLERID(name)} [Support]) same => n,Dial(PJSIP/1004&PJSIP/1005&PJSIP/1006,${RING_TIMEOUT},tTkK) same => n,VoiceMail(2002@${VM_CONTEXT},u) same => n,Hangup() ; ---- Conference rooms (3001-3009) ----
exten => _300X,1,NoOp(Conference Room ${EXTEN}) same => n,Answer() same => n,ConfBridge(${EXTEN},default_bridge,default_user) same => n,Hangup() ; ---- Admin conference (3000 with admin profile) ----
exten => 3000,1,NoOp(Admin Conference) same => n,Answer() same => n,ConfBridge(3000,default_bridge,admin_user) same => n,Hangup() ; ---- Voicemail access ----
exten => *97,1,NoOp(Voicemail Access) same => n,VoiceMailMain(${CALLERID(num)}@${VM_CONTEXT}) same => n,Hangup() ; ---- Voicemail access (other mailbox) ----
exten => *98,1,NoOp(Voicemail Access - Other Mailbox) same => n,VoiceMailMain(@${VM_CONTEXT}) same => n,Hangup() ; ---- Echo test ----
exten => *43,1,NoOp(Echo Test) same => n,Answer() same => n,Playback(demo-echotest) same => n,Echo() same => n,Playback(demo-echodone) same => n,Hangup() ; ---- Speaking clock ----
exten => *60,1,NoOp(Speaking Clock) same => n,Answer() same => n,SayUnixTime(,,IMp) same => n,Hangup() ; ---- Attended transfer ----
exten => _*2.,1,NoOp(Attended Transfer to ${EXTEN:2}) same => n,Dial(PJSIP/${EXTEN:2},${RING_TIMEOUT},tTkK) same => n,Hangup() ; ---- Outbound calls via trunk ----
; Dial 9 + number for outbound
exten => _9.,1,NoOp(Outbound call to ${EXTEN:1}) same => n,Set(CALLERID(all)=${TRUNK_CID}) same => n,Dial(PJSIP/${EXTEN:1}@my-trunk,60,tTkK) same => n,Hangup() ; ---- ARI-controlled calls ----
; Prefix with 7 to route to ARI application
exten => _7.,1,NoOp(ARI Application for ${EXTEN:1}) same => n,Stasis(${ARI_APP},${EXTEN:1}) same => n,Hangup() ; ---- Invalid extension ----
exten => i,1,Playback(invalid) same => n,Hangup() ; =============================================================================
; Inbound Calls Context (from SIP trunks)
; =============================================================================
[from-trunk]
; ---- Main IVR ----
exten => _X.,1,NoOp(Inbound call from ${CALLERID(num)} to ${EXTEN}) same => n,Answer() same => n,Wait(1) same => n,Set(TIMEOUT(response)=10) same => n,Set(TIMEOUT(digit)=5) same => n(ivr),Background(custom/welcome) same => n,WaitExten(5) same => n,Goto(ivr-timeout,s,1) ; IVR option 1: Sales
exten => 1,1,NoOp(IVR: Sales selected) same => n,Playback(custom/transferring-sales) same => n,Goto(from-internal,2001,1) ; IVR option 2: Support
exten => 2,1,NoOp(IVR: Support selected) same => n,Playback(custom/transferring-support) same => n,Goto(from-internal,2002,1) ; IVR option 0: Operator
exten => 0,1,NoOp(IVR: Operator selected) same => n,Goto(from-internal,1001,1) ; IVR timeout or invalid
[ivr-timeout]
exten => s,1,NoOp(IVR timeout - routing to reception) same => n,Playback(custom/no-input) same => n,Goto(from-internal,1001,1) ; =============================================================================
; Default Context (catch-all, drop unknown traffic)
; =============================================================================
[default]
exten => _X.,1,NoOp(Dropping unrouted call to ${EXTEN} from ${CALLERID(num)}) same => n,Hangup(21) exten => _[a-z].,1,Hangup(21)
; =============================================================================
; Dialplan - Containerized Asterisk
; ============================================================================= [general]
static = yes
writeprotect = no
clearglobalvars = no [globals]
; Voicemail context
VM_CONTEXT = default
; Ring time before voicemail (seconds)
RING_TIMEOUT = 25
; Company name for auto-attendant
COMPANY_NAME = "Acme Corp"
; ARI application name
ARI_APP = autoattendant
; External caller ID for outbound calls
TRUNK_CID = "Main Line" <+15551234567> ; =============================================================================
; Internal Calls Context
; =============================================================================
[from-internal]
; ---- Direct extension dialing (1001-1099) ----
exten => _10XX,1,NoOp(Internal call to ${EXTEN}) same => n,Set(CALLERID(name)=${CALLERID(name)}) same => n,Dial(PJSIP/${EXTEN},${RING_TIMEOUT},tTkK) same => n,GotoIf($["${DIALSTATUS}" = "BUSY"]?busy:unavail) same => n(busy),VoiceMail(${EXTEN}@${VM_CONTEXT},b) same => n,Hangup() same => n(unavail),VoiceMail(${EXTEN}@${VM_CONTEXT},u) same => n,Hangup() ; ---- Ring group: Sales (ring all sales extensions) ----
exten => 2001,1,NoOp(Ring Group: Sales) same => n,Set(CALLERID(name)=${CALLERID(name)} [Sales]) same => n,Dial(PJSIP/1001&PJSIP/1002&PJSIP/1003,${RING_TIMEOUT},tTkK) same => n,VoiceMail(2001@${VM_CONTEXT},u) same => n,Hangup() ; ---- Ring group: Support ----
exten => 2002,1,NoOp(Ring Group: Support) same => n,Set(CALLERID(name)=${CALLERID(name)} [Support]) same => n,Dial(PJSIP/1004&PJSIP/1005&PJSIP/1006,${RING_TIMEOUT},tTkK) same => n,VoiceMail(2002@${VM_CONTEXT},u) same => n,Hangup() ; ---- Conference rooms (3001-3009) ----
exten => _300X,1,NoOp(Conference Room ${EXTEN}) same => n,Answer() same => n,ConfBridge(${EXTEN},default_bridge,default_user) same => n,Hangup() ; ---- Admin conference (3000 with admin profile) ----
exten => 3000,1,NoOp(Admin Conference) same => n,Answer() same => n,ConfBridge(3000,default_bridge,admin_user) same => n,Hangup() ; ---- Voicemail access ----
exten => *97,1,NoOp(Voicemail Access) same => n,VoiceMailMain(${CALLERID(num)}@${VM_CONTEXT}) same => n,Hangup() ; ---- Voicemail access (other mailbox) ----
exten => *98,1,NoOp(Voicemail Access - Other Mailbox) same => n,VoiceMailMain(@${VM_CONTEXT}) same => n,Hangup() ; ---- Echo test ----
exten => *43,1,NoOp(Echo Test) same => n,Answer() same => n,Playback(demo-echotest) same => n,Echo() same => n,Playback(demo-echodone) same => n,Hangup() ; ---- Speaking clock ----
exten => *60,1,NoOp(Speaking Clock) same => n,Answer() same => n,SayUnixTime(,,IMp) same => n,Hangup() ; ---- Attended transfer ----
exten => _*2.,1,NoOp(Attended Transfer to ${EXTEN:2}) same => n,Dial(PJSIP/${EXTEN:2},${RING_TIMEOUT},tTkK) same => n,Hangup() ; ---- Outbound calls via trunk ----
; Dial 9 + number for outbound
exten => _9.,1,NoOp(Outbound call to ${EXTEN:1}) same => n,Set(CALLERID(all)=${TRUNK_CID}) same => n,Dial(PJSIP/${EXTEN:1}@my-trunk,60,tTkK) same => n,Hangup() ; ---- ARI-controlled calls ----
; Prefix with 7 to route to ARI application
exten => _7.,1,NoOp(ARI Application for ${EXTEN:1}) same => n,Stasis(${ARI_APP},${EXTEN:1}) same => n,Hangup() ; ---- Invalid extension ----
exten => i,1,Playback(invalid) same => n,Hangup() ; =============================================================================
; Inbound Calls Context (from SIP trunks)
; =============================================================================
[from-trunk]
; ---- Main IVR ----
exten => _X.,1,NoOp(Inbound call from ${CALLERID(num)} to ${EXTEN}) same => n,Answer() same => n,Wait(1) same => n,Set(TIMEOUT(response)=10) same => n,Set(TIMEOUT(digit)=5) same => n(ivr),Background(custom/welcome) same => n,WaitExten(5) same => n,Goto(ivr-timeout,s,1) ; IVR option 1: Sales
exten => 1,1,NoOp(IVR: Sales selected) same => n,Playback(custom/transferring-sales) same => n,Goto(from-internal,2001,1) ; IVR option 2: Support
exten => 2,1,NoOp(IVR: Support selected) same => n,Playback(custom/transferring-support) same => n,Goto(from-internal,2002,1) ; IVR option 0: Operator
exten => 0,1,NoOp(IVR: Operator selected) same => n,Goto(from-internal,1001,1) ; IVR timeout or invalid
[ivr-timeout]
exten => s,1,NoOp(IVR timeout - routing to reception) same => n,Playback(custom/no-input) same => n,Goto(from-internal,1001,1) ; =============================================================================
; Default Context (catch-all, drop unknown traffic)
; =============================================================================
[default]
exten => _X.,1,NoOp(Dropping unrouted call to ${EXTEN} from ${CALLERID(num)}) same => n,Hangup(21) exten => _[a-z].,1,Hangup(21)
; =============================================================================
; Dialplan - Containerized Asterisk
; ============================================================================= [general]
static = yes
writeprotect = no
clearglobalvars = no [globals]
; Voicemail context
VM_CONTEXT = default
; Ring time before voicemail (seconds)
RING_TIMEOUT = 25
; Company name for auto-attendant
COMPANY_NAME = "Acme Corp"
; ARI application name
ARI_APP = autoattendant
; External caller ID for outbound calls
TRUNK_CID = "Main Line" <+15551234567> ; =============================================================================
; Internal Calls Context
; =============================================================================
[from-internal]
; ---- Direct extension dialing (1001-1099) ----
exten => _10XX,1,NoOp(Internal call to ${EXTEN}) same => n,Set(CALLERID(name)=${CALLERID(name)}) same => n,Dial(PJSIP/${EXTEN},${RING_TIMEOUT},tTkK) same => n,GotoIf($["${DIALSTATUS}" = "BUSY"]?busy:unavail) same => n(busy),VoiceMail(${EXTEN}@${VM_CONTEXT},b) same => n,Hangup() same => n(unavail),VoiceMail(${EXTEN}@${VM_CONTEXT},u) same => n,Hangup() ; ---- Ring group: Sales (ring all sales extensions) ----
exten => 2001,1,NoOp(Ring Group: Sales) same => n,Set(CALLERID(name)=${CALLERID(name)} [Sales]) same => n,Dial(PJSIP/1001&PJSIP/1002&PJSIP/1003,${RING_TIMEOUT},tTkK) same => n,VoiceMail(2001@${VM_CONTEXT},u) same => n,Hangup() ; ---- Ring group: Support ----
exten => 2002,1,NoOp(Ring Group: Support) same => n,Set(CALLERID(name)=${CALLERID(name)} [Support]) same => n,Dial(PJSIP/1004&PJSIP/1005&PJSIP/1006,${RING_TIMEOUT},tTkK) same => n,VoiceMail(2002@${VM_CONTEXT},u) same => n,Hangup() ; ---- Conference rooms (3001-3009) ----
exten => _300X,1,NoOp(Conference Room ${EXTEN}) same => n,Answer() same => n,ConfBridge(${EXTEN},default_bridge,default_user) same => n,Hangup() ; ---- Admin conference (3000 with admin profile) ----
exten => 3000,1,NoOp(Admin Conference) same => n,Answer() same => n,ConfBridge(3000,default_bridge,admin_user) same => n,Hangup() ; ---- Voicemail access ----
exten => *97,1,NoOp(Voicemail Access) same => n,VoiceMailMain(${CALLERID(num)}@${VM_CONTEXT}) same => n,Hangup() ; ---- Voicemail access (other mailbox) ----
exten => *98,1,NoOp(Voicemail Access - Other Mailbox) same => n,VoiceMailMain(@${VM_CONTEXT}) same => n,Hangup() ; ---- Echo test ----
exten => *43,1,NoOp(Echo Test) same => n,Answer() same => n,Playback(demo-echotest) same => n,Echo() same => n,Playback(demo-echodone) same => n,Hangup() ; ---- Speaking clock ----
exten => *60,1,NoOp(Speaking Clock) same => n,Answer() same => n,SayUnixTime(,,IMp) same => n,Hangup() ; ---- Attended transfer ----
exten => _*2.,1,NoOp(Attended Transfer to ${EXTEN:2}) same => n,Dial(PJSIP/${EXTEN:2},${RING_TIMEOUT},tTkK) same => n,Hangup() ; ---- Outbound calls via trunk ----
; Dial 9 + number for outbound
exten => _9.,1,NoOp(Outbound call to ${EXTEN:1}) same => n,Set(CALLERID(all)=${TRUNK_CID}) same => n,Dial(PJSIP/${EXTEN:1}@my-trunk,60,tTkK) same => n,Hangup() ; ---- ARI-controlled calls ----
; Prefix with 7 to route to ARI application
exten => _7.,1,NoOp(ARI Application for ${EXTEN:1}) same => n,Stasis(${ARI_APP},${EXTEN:1}) same => n,Hangup() ; ---- Invalid extension ----
exten => i,1,Playback(invalid) same => n,Hangup() ; =============================================================================
; Inbound Calls Context (from SIP trunks)
; =============================================================================
[from-trunk]
; ---- Main IVR ----
exten => _X.,1,NoOp(Inbound call from ${CALLERID(num)} to ${EXTEN}) same => n,Answer() same => n,Wait(1) same => n,Set(TIMEOUT(response)=10) same => n,Set(TIMEOUT(digit)=5) same => n(ivr),Background(custom/welcome) same => n,WaitExten(5) same => n,Goto(ivr-timeout,s,1) ; IVR option 1: Sales
exten => 1,1,NoOp(IVR: Sales selected) same => n,Playback(custom/transferring-sales) same => n,Goto(from-internal,2001,1) ; IVR option 2: Support
exten => 2,1,NoOp(IVR: Support selected) same => n,Playback(custom/transferring-support) same => n,Goto(from-internal,2002,1) ; IVR option 0: Operator
exten => 0,1,NoOp(IVR: Operator selected) same => n,Goto(from-internal,1001,1) ; IVR timeout or invalid
[ivr-timeout]
exten => s,1,NoOp(IVR timeout - routing to reception) same => n,Playback(custom/no-input) same => n,Goto(from-internal,1001,1) ; =============================================================================
; Default Context (catch-all, drop unknown traffic)
; =============================================================================
[default]
exten => _X.,1,NoOp(Dropping unrouted call to ${EXTEN} from ${CALLERID(num)}) same => n,Hangup(21) exten => _[a-z].,1,Hangup(21)
; =============================================================================
; Module Loading Configuration
; Load only what we need for a smaller memory footprint
; ============================================================================= [modules]
autoload = no ; ---- Core / Resource ----
load = res_pjproject.so
load = res_sorcery_astdb.so
load = res_sorcery_config.so
load = res_sorcery_memory.so
load = res_sorcery_memory_cache.so
load = res_sorcery_realtime.so
load = res_timing_timerfd.so
load = res_crypto.so
load = res_http_websocket.so
load = res_musiconhold.so
load = res_rtp_asterisk.so
load = res_rtp_multicast.so
load = res_speech.so
load = res_stasis.so
load = res_stasis_answer.so
load = res_stasis_playback.so
load = res_stasis_recording.so
load = res_stasis_snoop.so
load = res_stasis_device_state.so ; ---- PJSIP ----
load = res_pjsip.so
load = res_pjsip_authenticator_digest.so
load = res_pjsip_caller_id.so
load = res_pjsip_dialog_info_body_generator.so
load = res_pjsip_diversion.so
load = res_pjsip_dtmf_info.so
load = res_pjsip_endpoint_identifier_ip.so
load = res_pjsip_endpoint_identifier_user.so
load = res_pjsip_exten_state.so
load = res_pjsip_header_funcs.so
load = res_pjsip_logger.so
load = res_pjsip_messaging.so
load = res_pjsip_mwi.so
load = res_pjsip_mwi_body_generator.so
load = res_pjsip_nat.so
load = res_pjsip_notify.so
load = res_pjsip_outbound_authenticator_digest.so
load = res_pjsip_outbound_registration.so
load = res_pjsip_pidf_body_generator.so
load = res_pjsip_pidf_digium_body_supplement.so
load = res_pjsip_pidf_eyebeam_body_supplement.so
load = res_pjsip_publish_asterisk.so
load = res_pjsip_pubsub.so
load = res_pjsip_refer.so
load = res_pjsip_registrar.so
load = res_pjsip_rfc3326.so
load = res_pjsip_sdp_rtp.so
load = res_pjsip_send_to_voicemail.so
load = res_pjsip_session.so
load = res_pjsip_t38.so
load = res_pjsip_transport_websocket.so ; ---- ARI (Asterisk REST Interface) ----
load = res_ari.so
load = res_ari_applications.so
load = res_ari_asterisk.so
load = res_ari_bridges.so
load = res_ari_channels.so
load = res_ari_device_states.so
load = res_ari_endpoints.so
load = res_ari_events.so
load = res_ari_mailboxes.so
load = res_ari_model.so
load = res_ari_playbacks.so
load = res_ari_recordings.so
load = res_ari_sounds.so ; ---- Applications ----
load = app_bridgewait.so
load = app_confbridge.so
load = app_dial.so
load = app_directed_pickup.so
load = app_echo.so
load = app_exec.so
load = app_macro.so
load = app_mixmonitor.so
load = app_originate.so
load = app_playback.so
load = app_queue.so
load = app_read.so
load = app_record.so
load = app_sayunixtime.so
load = app_senddtmf.so
load = app_stack.so
load = app_stasis.so
load = app_transfer.so
load = app_verbose.so
load = app_voicemail.so
load = app_waituntil.so ; ---- Bridging ----
load = bridge_builtin_features.so
load = bridge_builtin_interval_features.so
load = bridge_holding.so
load = bridge_native_rtp.so
load = bridge_simple.so
load = bridge_softmix.so ; ---- CDR ----
load = cdr_adaptive_odbc.so
load = cdr_csv.so
load = cdr_custom.so
load = cdr_manager.so ; ---- Channel ----
load = chan_bridge_media.so
load = chan_pjsip.so
load = chan_rtp.so ; ---- Codecs ----
load = codec_a_mu.so
load = codec_adpcm.so
load = codec_alaw.so
load = codec_g722.so
load = codec_g726.so
load = codec_gsm.so
load = codec_opus.so
load = codec_resample.so
load = codec_speex.so
load = codec_ulaw.so ; ---- Formats ----
load = format_g722.so
load = format_g726.so
load = format_gsm.so
load = format_mp3.so
load = format_ogg_vorbis.so
load = format_pcm.so
load = format_sln.so
load = format_wav.so
load = format_wav_gsm.so ; ---- Functions ----
load = func_base64.so
load = func_callerid.so
load = func_cdr.so
load = func_channel.so
load = func_config.so
load = func_curl.so
load = func_cut.so
load = func_db.so
load = func_devstate.so
load = func_dialgroup.so
load = func_dialplan.so
load = func_env.so
load = func_global.so
load = func_groupcount.so
load = func_hangupcause.so
load = func_logic.so
load = func_math.so
load = func_md5.so
load = func_module.so
load = func_odbc.so
load = func_periodic_hook.so
load = func_pjsip_aor.so
load = func_pjsip_contact.so
load = func_pjsip_endpoint.so
load = func_realtime.so
load = func_shell.so
load = func_sprintf.so
load = func_strings.so
load = func_sysinfo.so
load = func_timeout.so
load = func_uri.so
load = func_version.so
load = func_volume.so ; ---- PBX ----
load = pbx_config.so
load = pbx_lounge.so
load = pbx_realtime.so ; ---- Database ----
load = res_config_odbc.so
load = res_odbc.so
load = res_odbc_transaction.so
load = func_realtime.so
; =============================================================================
; Module Loading Configuration
; Load only what we need for a smaller memory footprint
; ============================================================================= [modules]
autoload = no ; ---- Core / Resource ----
load = res_pjproject.so
load = res_sorcery_astdb.so
load = res_sorcery_config.so
load = res_sorcery_memory.so
load = res_sorcery_memory_cache.so
load = res_sorcery_realtime.so
load = res_timing_timerfd.so
load = res_crypto.so
load = res_http_websocket.so
load = res_musiconhold.so
load = res_rtp_asterisk.so
load = res_rtp_multicast.so
load = res_speech.so
load = res_stasis.so
load = res_stasis_answer.so
load = res_stasis_playback.so
load = res_stasis_recording.so
load = res_stasis_snoop.so
load = res_stasis_device_state.so ; ---- PJSIP ----
load = res_pjsip.so
load = res_pjsip_authenticator_digest.so
load = res_pjsip_caller_id.so
load = res_pjsip_dialog_info_body_generator.so
load = res_pjsip_diversion.so
load = res_pjsip_dtmf_info.so
load = res_pjsip_endpoint_identifier_ip.so
load = res_pjsip_endpoint_identifier_user.so
load = res_pjsip_exten_state.so
load = res_pjsip_header_funcs.so
load = res_pjsip_logger.so
load = res_pjsip_messaging.so
load = res_pjsip_mwi.so
load = res_pjsip_mwi_body_generator.so
load = res_pjsip_nat.so
load = res_pjsip_notify.so
load = res_pjsip_outbound_authenticator_digest.so
load = res_pjsip_outbound_registration.so
load = res_pjsip_pidf_body_generator.so
load = res_pjsip_pidf_digium_body_supplement.so
load = res_pjsip_pidf_eyebeam_body_supplement.so
load = res_pjsip_publish_asterisk.so
load = res_pjsip_pubsub.so
load = res_pjsip_refer.so
load = res_pjsip_registrar.so
load = res_pjsip_rfc3326.so
load = res_pjsip_sdp_rtp.so
load = res_pjsip_send_to_voicemail.so
load = res_pjsip_session.so
load = res_pjsip_t38.so
load = res_pjsip_transport_websocket.so ; ---- ARI (Asterisk REST Interface) ----
load = res_ari.so
load = res_ari_applications.so
load = res_ari_asterisk.so
load = res_ari_bridges.so
load = res_ari_channels.so
load = res_ari_device_states.so
load = res_ari_endpoints.so
load = res_ari_events.so
load = res_ari_mailboxes.so
load = res_ari_model.so
load = res_ari_playbacks.so
load = res_ari_recordings.so
load = res_ari_sounds.so ; ---- Applications ----
load = app_bridgewait.so
load = app_confbridge.so
load = app_dial.so
load = app_directed_pickup.so
load = app_echo.so
load = app_exec.so
load = app_macro.so
load = app_mixmonitor.so
load = app_originate.so
load = app_playback.so
load = app_queue.so
load = app_read.so
load = app_record.so
load = app_sayunixtime.so
load = app_senddtmf.so
load = app_stack.so
load = app_stasis.so
load = app_transfer.so
load = app_verbose.so
load = app_voicemail.so
load = app_waituntil.so ; ---- Bridging ----
load = bridge_builtin_features.so
load = bridge_builtin_interval_features.so
load = bridge_holding.so
load = bridge_native_rtp.so
load = bridge_simple.so
load = bridge_softmix.so ; ---- CDR ----
load = cdr_adaptive_odbc.so
load = cdr_csv.so
load = cdr_custom.so
load = cdr_manager.so ; ---- Channel ----
load = chan_bridge_media.so
load = chan_pjsip.so
load = chan_rtp.so ; ---- Codecs ----
load = codec_a_mu.so
load = codec_adpcm.so
load = codec_alaw.so
load = codec_g722.so
load = codec_g726.so
load = codec_gsm.so
load = codec_opus.so
load = codec_resample.so
load = codec_speex.so
load = codec_ulaw.so ; ---- Formats ----
load = format_g722.so
load = format_g726.so
load = format_gsm.so
load = format_mp3.so
load = format_ogg_vorbis.so
load = format_pcm.so
load = format_sln.so
load = format_wav.so
load = format_wav_gsm.so ; ---- Functions ----
load = func_base64.so
load = func_callerid.so
load = func_cdr.so
load = func_channel.so
load = func_config.so
load = func_curl.so
load = func_cut.so
load = func_db.so
load = func_devstate.so
load = func_dialgroup.so
load = func_dialplan.so
load = func_env.so
load = func_global.so
load = func_groupcount.so
load = func_hangupcause.so
load = func_logic.so
load = func_math.so
load = func_md5.so
load = func_module.so
load = func_odbc.so
load = func_periodic_hook.so
load = func_pjsip_aor.so
load = func_pjsip_contact.so
load = func_pjsip_endpoint.so
load = func_realtime.so
load = func_shell.so
load = func_sprintf.so
load = func_strings.so
load = func_sysinfo.so
load = func_timeout.so
load = func_uri.so
load = func_version.so
load = func_volume.so ; ---- PBX ----
load = pbx_config.so
load = pbx_lounge.so
load = pbx_realtime.so ; ---- Database ----
load = res_config_odbc.so
load = res_odbc.so
load = res_odbc_transaction.so
load = func_realtime.so
; =============================================================================
; Module Loading Configuration
; Load only what we need for a smaller memory footprint
; ============================================================================= [modules]
autoload = no ; ---- Core / Resource ----
load = res_pjproject.so
load = res_sorcery_astdb.so
load = res_sorcery_config.so
load = res_sorcery_memory.so
load = res_sorcery_memory_cache.so
load = res_sorcery_realtime.so
load = res_timing_timerfd.so
load = res_crypto.so
load = res_http_websocket.so
load = res_musiconhold.so
load = res_rtp_asterisk.so
load = res_rtp_multicast.so
load = res_speech.so
load = res_stasis.so
load = res_stasis_answer.so
load = res_stasis_playback.so
load = res_stasis_recording.so
load = res_stasis_snoop.so
load = res_stasis_device_state.so ; ---- PJSIP ----
load = res_pjsip.so
load = res_pjsip_authenticator_digest.so
load = res_pjsip_caller_id.so
load = res_pjsip_dialog_info_body_generator.so
load = res_pjsip_diversion.so
load = res_pjsip_dtmf_info.so
load = res_pjsip_endpoint_identifier_ip.so
load = res_pjsip_endpoint_identifier_user.so
load = res_pjsip_exten_state.so
load = res_pjsip_header_funcs.so
load = res_pjsip_logger.so
load = res_pjsip_messaging.so
load = res_pjsip_mwi.so
load = res_pjsip_mwi_body_generator.so
load = res_pjsip_nat.so
load = res_pjsip_notify.so
load = res_pjsip_outbound_authenticator_digest.so
load = res_pjsip_outbound_registration.so
load = res_pjsip_pidf_body_generator.so
load = res_pjsip_pidf_digium_body_supplement.so
load = res_pjsip_pidf_eyebeam_body_supplement.so
load = res_pjsip_publish_asterisk.so
load = res_pjsip_pubsub.so
load = res_pjsip_refer.so
load = res_pjsip_registrar.so
load = res_pjsip_rfc3326.so
load = res_pjsip_sdp_rtp.so
load = res_pjsip_send_to_voicemail.so
load = res_pjsip_session.so
load = res_pjsip_t38.so
load = res_pjsip_transport_websocket.so ; ---- ARI (Asterisk REST Interface) ----
load = res_ari.so
load = res_ari_applications.so
load = res_ari_asterisk.so
load = res_ari_bridges.so
load = res_ari_channels.so
load = res_ari_device_states.so
load = res_ari_endpoints.so
load = res_ari_events.so
load = res_ari_mailboxes.so
load = res_ari_model.so
load = res_ari_playbacks.so
load = res_ari_recordings.so
load = res_ari_sounds.so ; ---- Applications ----
load = app_bridgewait.so
load = app_confbridge.so
load = app_dial.so
load = app_directed_pickup.so
load = app_echo.so
load = app_exec.so
load = app_macro.so
load = app_mixmonitor.so
load = app_originate.so
load = app_playback.so
load = app_queue.so
load = app_read.so
load = app_record.so
load = app_sayunixtime.so
load = app_senddtmf.so
load = app_stack.so
load = app_stasis.so
load = app_transfer.so
load = app_verbose.so
load = app_voicemail.so
load = app_waituntil.so ; ---- Bridging ----
load = bridge_builtin_features.so
load = bridge_builtin_interval_features.so
load = bridge_holding.so
load = bridge_native_rtp.so
load = bridge_simple.so
load = bridge_softmix.so ; ---- CDR ----
load = cdr_adaptive_odbc.so
load = cdr_csv.so
load = cdr_custom.so
load = cdr_manager.so ; ---- Channel ----
load = chan_bridge_media.so
load = chan_pjsip.so
load = chan_rtp.so ; ---- Codecs ----
load = codec_a_mu.so
load = codec_adpcm.so
load = codec_alaw.so
load = codec_g722.so
load = codec_g726.so
load = codec_gsm.so
load = codec_opus.so
load = codec_resample.so
load = codec_speex.so
load = codec_ulaw.so ; ---- Formats ----
load = format_g722.so
load = format_g726.so
load = format_gsm.so
load = format_mp3.so
load = format_ogg_vorbis.so
load = format_pcm.so
load = format_sln.so
load = format_wav.so
load = format_wav_gsm.so ; ---- Functions ----
load = func_base64.so
load = func_callerid.so
load = func_cdr.so
load = func_channel.so
load = func_config.so
load = func_curl.so
load = func_cut.so
load = func_db.so
load = func_devstate.so
load = func_dialgroup.so
load = func_dialplan.so
load = func_env.so
load = func_global.so
load = func_groupcount.so
load = func_hangupcause.so
load = func_logic.so
load = func_math.so
load = func_md5.so
load = func_module.so
load = func_odbc.so
load = func_periodic_hook.so
load = func_pjsip_aor.so
load = func_pjsip_contact.so
load = func_pjsip_endpoint.so
load = func_realtime.so
load = func_shell.so
load = func_sprintf.so
load = func_strings.so
load = func_sysinfo.so
load = func_timeout.so
load = func_uri.so
load = func_version.so
load = func_volume.so ; ---- PBX ----
load = pbx_config.so
load = pbx_lounge.so
load = pbx_realtime.so ; ---- Database ----
load = res_config_odbc.so
load = res_odbc.so
load = res_odbc_transaction.so
load = func_realtime.so
; =============================================================================
; HTTP Server Configuration (for ARI and WebSocket)
; ============================================================================= [general]
enabled = yes
bindaddr = 0.0.0.0
bindport = 8088
; TLS is handled by Nginx reverse proxy
; For direct TLS access, uncomment below:
; tlsenable = yes
; tlsbindaddr = 0.0.0.0:8089
; tlscertfile = /etc/asterisk/certs/live/${DOMAIN}/fullchain.pem
; tlsprivatekey = /etc/asterisk/certs/live/${DOMAIN}/privkey.pem
prefix =
sessionlimit = 100
session_inactivity = 30000
session_keep_alive = 15000
; =============================================================================
; HTTP Server Configuration (for ARI and WebSocket)
; ============================================================================= [general]
enabled = yes
bindaddr = 0.0.0.0
bindport = 8088
; TLS is handled by Nginx reverse proxy
; For direct TLS access, uncomment below:
; tlsenable = yes
; tlsbindaddr = 0.0.0.0:8089
; tlscertfile = /etc/asterisk/certs/live/${DOMAIN}/fullchain.pem
; tlsprivatekey = /etc/asterisk/certs/live/${DOMAIN}/privkey.pem
prefix =
sessionlimit = 100
session_inactivity = 30000
session_keep_alive = 15000
; =============================================================================
; HTTP Server Configuration (for ARI and WebSocket)
; ============================================================================= [general]
enabled = yes
bindaddr = 0.0.0.0
bindport = 8088
; TLS is handled by Nginx reverse proxy
; For direct TLS access, uncomment below:
; tlsenable = yes
; tlsbindaddr = 0.0.0.0:8089
; tlscertfile = /etc/asterisk/certs/live/${DOMAIN}/fullchain.pem
; tlsprivatekey = /etc/asterisk/certs/live/${DOMAIN}/privkey.pem
prefix =
sessionlimit = 100
session_inactivity = 30000
session_keep_alive = 15000
; =============================================================================
; ARI (Asterisk REST Interface) Configuration
; ============================================================================= [general]
enabled = yes
pretty = yes
allowed_origins = https://${DOMAIN} [${ARI_USERNAME}]
type = user
read_only = no
password = ${ARI_PASSWORD}
; =============================================================================
; ARI (Asterisk REST Interface) Configuration
; ============================================================================= [general]
enabled = yes
pretty = yes
allowed_origins = https://${DOMAIN} [${ARI_USERNAME}]
type = user
read_only = no
password = ${ARI_PASSWORD}
; =============================================================================
; ARI (Asterisk REST Interface) Configuration
; ============================================================================= [general]
enabled = yes
pretty = yes
allowed_origins = https://${DOMAIN} [${ARI_USERNAME}]
type = user
read_only = no
password = ${ARI_PASSWORD}
; =============================================================================
; RTP Configuration
; Port range MUST match the Docker port mapping in docker-compose.yml
; ============================================================================= [general]
rtpstart = 10000
rtpend = 20000
; Strict RTP - helps prevent RTP injection attacks
strictrtp = yes
; Probation count before RTP source is accepted
probation = 4
; Enable symmetric RTP (important for NAT)
icesupport = yes
stunaddr = stun.l.google.com:19302
; RTP keepalives (prevent NAT timeout)
rtpkeepalive = 15
; DTMF timeout
dtmftimeout = 3000
; =============================================================================
; RTP Configuration
; Port range MUST match the Docker port mapping in docker-compose.yml
; ============================================================================= [general]
rtpstart = 10000
rtpend = 20000
; Strict RTP - helps prevent RTP injection attacks
strictrtp = yes
; Probation count before RTP source is accepted
probation = 4
; Enable symmetric RTP (important for NAT)
icesupport = yes
stunaddr = stun.l.google.com:19302
; RTP keepalives (prevent NAT timeout)
rtpkeepalive = 15
; DTMF timeout
dtmftimeout = 3000
; =============================================================================
; RTP Configuration
; Port range MUST match the Docker port mapping in docker-compose.yml
; ============================================================================= [general]
rtpstart = 10000
rtpend = 20000
; Strict RTP - helps prevent RTP injection attacks
strictrtp = yes
; Probation count before RTP source is accepted
probation = 4
; Enable symmetric RTP (important for NAT)
icesupport = yes
stunaddr = stun.l.google.com:19302
; RTP keepalives (prevent NAT timeout)
rtpkeepalive = 15
; DTMF timeout
dtmftimeout = 3000
; =============================================================================
; CDR (Call Detail Records) Configuration
; ============================================================================= [general]
enable = yes
unanswered = yes
congestion = yes
endbeforehexten = no
initiatedseconds = yes
batch = yes
size = 100
time = 300
; =============================================================================
; CDR (Call Detail Records) Configuration
; ============================================================================= [general]
enable = yes
unanswered = yes
congestion = yes
endbeforehexten = no
initiatedseconds = yes
batch = yes
size = 100
time = 300
; =============================================================================
; CDR (Call Detail Records) Configuration
; ============================================================================= [general]
enable = yes
unanswered = yes
congestion = yes
endbeforehexten = no
initiatedseconds = yes
batch = yes
size = 100
time = 300
; =============================================================================
; CDR Adaptive ODBC - Write CDR to MariaDB
; ============================================================================= [asterisk-cdr]
connection = asterisk-connector
table = cdr
alias start => calldate
alias clid => clid
alias src => src
alias dst => dst
alias dcontext => dcontext
alias channel => channel
alias dstchannel => dstchannel
alias lastapp => lastapp
alias lastdata => lastdata
alias duration => duration
alias billsec => billsec
alias disposition => disposition
alias amaflags => amaflags
alias accountcode => accountcode
alias uniqueid => uniqueid
alias userfield => userfield
alias peeraccount => peeraccount
alias linkedid => linkedid
alias sequence => sequence
; =============================================================================
; CDR Adaptive ODBC - Write CDR to MariaDB
; ============================================================================= [asterisk-cdr]
connection = asterisk-connector
table = cdr
alias start => calldate
alias clid => clid
alias src => src
alias dst => dst
alias dcontext => dcontext
alias channel => channel
alias dstchannel => dstchannel
alias lastapp => lastapp
alias lastdata => lastdata
alias duration => duration
alias billsec => billsec
alias disposition => disposition
alias amaflags => amaflags
alias accountcode => accountcode
alias uniqueid => uniqueid
alias userfield => userfield
alias peeraccount => peeraccount
alias linkedid => linkedid
alias sequence => sequence
; =============================================================================
; CDR Adaptive ODBC - Write CDR to MariaDB
; ============================================================================= [asterisk-cdr]
connection = asterisk-connector
table = cdr
alias start => calldate
alias clid => clid
alias src => src
alias dst => dst
alias dcontext => dcontext
alias channel => channel
alias dstchannel => dstchannel
alias lastapp => lastapp
alias lastdata => lastdata
alias duration => duration
alias billsec => billsec
alias disposition => disposition
alias amaflags => amaflags
alias accountcode => accountcode
alias uniqueid => uniqueid
alias userfield => userfield
alias peeraccount => peeraccount
alias linkedid => linkedid
alias sequence => sequence
; =============================================================================
; ODBC Connection Configuration
; ============================================================================= [asterisk-connector]
enabled = yes
dsn = asterisk-connector
username = ${DB_USER}
password = ${DB_PASSWORD}
pre-connect = yes
sanitysql = SELECT 1
max_connections = 20
connect_timeout = 5
negative_connection_cache = 600
; =============================================================================
; ODBC Connection Configuration
; ============================================================================= [asterisk-connector]
enabled = yes
dsn = asterisk-connector
username = ${DB_USER}
password = ${DB_PASSWORD}
pre-connect = yes
sanitysql = SELECT 1
max_connections = 20
connect_timeout = 5
negative_connection_cache = 600
; =============================================================================
; ODBC Connection Configuration
; ============================================================================= [asterisk-connector]
enabled = yes
dsn = asterisk-connector
username = ${DB_USER}
password = ${DB_PASSWORD}
pre-connect = yes
sanitysql = SELECT 1
max_connections = 20
connect_timeout = 5
negative_connection_cache = 600
[asterisk-connector]
Description = MariaDB Asterisk Connection
Driver = /usr/lib/x86_64-linux-gnu/odbc/libmaodbc.so
Server = ${DB_HOST}
Port = ${DB_PORT}
Database = ${DB_NAME}
Option = 3
Socket =
[asterisk-connector]
Description = MariaDB Asterisk Connection
Driver = /usr/lib/x86_64-linux-gnu/odbc/libmaodbc.so
Server = ${DB_HOST}
Port = ${DB_PORT}
Database = ${DB_NAME}
Option = 3
Socket =
[asterisk-connector]
Description = MariaDB Asterisk Connection
Driver = /usr/lib/x86_64-linux-gnu/odbc/libmaodbc.so
Server = ${DB_HOST}
Port = ${DB_PORT}
Database = ${DB_NAME}
Option = 3
Socket =
; =============================================================================
; Voicemail Configuration
; ============================================================================= [general]
format = wav49|gsm|wav
serveremail = voicemail@${DOMAIN}
attach = yes
skipms = 3000
maxsilence = 10
silencethreshold = 128
maxlogins = 3
minsecs = 3
maxsecs = 300
maxmsg = 100
moveheard = yes
forward_urgent_auto = yes
; Email notification (requires sendmail/msmtp in container)
; emailsubject = New voicemail ${VM_MSGNUM} in mailbox ${VM_MAILBOX}
; emailbody = Dear ${VM_NAME},\n\nYou have a new voicemail from ${VM_CALLERID}\nDuration: ${VM_DUR}\nDate: ${VM_DATE}\n
emaildateformat = %A, %B %d, %Y at %r [default]
1001 => 1234,Reception,reception@${DOMAIN},,
1002 => 1234,Sales,sales@${DOMAIN},,
1003 => 1234,Sales 2,,,
1004 => 1234,Support,support@${DOMAIN},,
1005 => 1234,Support 2,,,
1006 => 1234,Support 3,,,
1010 => 1234,Web Phone,webphone@${DOMAIN},,
2001 => 0000,Sales Group,sales@${DOMAIN},,
2002 => 0000,Support Group,support@${DOMAIN},,
; =============================================================================
; Voicemail Configuration
; ============================================================================= [general]
format = wav49|gsm|wav
serveremail = voicemail@${DOMAIN}
attach = yes
skipms = 3000
maxsilence = 10
silencethreshold = 128
maxlogins = 3
minsecs = 3
maxsecs = 300
maxmsg = 100
moveheard = yes
forward_urgent_auto = yes
; Email notification (requires sendmail/msmtp in container)
; emailsubject = New voicemail ${VM_MSGNUM} in mailbox ${VM_MAILBOX}
; emailbody = Dear ${VM_NAME},\n\nYou have a new voicemail from ${VM_CALLERID}\nDuration: ${VM_DUR}\nDate: ${VM_DATE}\n
emaildateformat = %A, %B %d, %Y at %r [default]
1001 => 1234,Reception,reception@${DOMAIN},,
1002 => 1234,Sales,sales@${DOMAIN},,
1003 => 1234,Sales 2,,,
1004 => 1234,Support,support@${DOMAIN},,
1005 => 1234,Support 2,,,
1006 => 1234,Support 3,,,
1010 => 1234,Web Phone,webphone@${DOMAIN},,
2001 => 0000,Sales Group,sales@${DOMAIN},,
2002 => 0000,Support Group,support@${DOMAIN},,
; =============================================================================
; Voicemail Configuration
; ============================================================================= [general]
format = wav49|gsm|wav
serveremail = voicemail@${DOMAIN}
attach = yes
skipms = 3000
maxsilence = 10
silencethreshold = 128
maxlogins = 3
minsecs = 3
maxsecs = 300
maxmsg = 100
moveheard = yes
forward_urgent_auto = yes
; Email notification (requires sendmail/msmtp in container)
; emailsubject = New voicemail ${VM_MSGNUM} in mailbox ${VM_MAILBOX}
; emailbody = Dear ${VM_NAME},\n\nYou have a new voicemail from ${VM_CALLERID}\nDuration: ${VM_DUR}\nDate: ${VM_DATE}\n
emaildateformat = %A, %B %d, %Y at %r [default]
1001 => 1234,Reception,reception@${DOMAIN},,
1002 => 1234,Sales,sales@${DOMAIN},,
1003 => 1234,Sales 2,,,
1004 => 1234,Support,support@${DOMAIN},,
1005 => 1234,Support 2,,,
1006 => 1234,Support 3,,,
1010 => 1234,Web Phone,webphone@${DOMAIN},,
2001 => 0000,Sales Group,sales@${DOMAIN},,
2002 => 0000,Support Group,support@${DOMAIN},,
; =============================================================================
; ConfBridge Configuration (Conference Rooms)
; Uses res_timing_timerfd - no DAHDI needed
; ============================================================================= [general] [default_bridge]
type = bridge
max_members = 50
record_conference = no
internal_sample_rate = auto
mixing_interval = 20
video_mode = follow_talker
sound_join = confbridge-join
sound_leave = confbridge-leave
sound_has_joined = confbridge-has-joined
sound_has_left = confbridge-has-left [default_user]
type = user
announce_user_count = yes
announce_user_count_all = yes
announce_join_leave = yes
music_on_hold_when_empty = yes
quiet = no
startmuted = no
wait_marked = no
end_marked = no
dsp_drop_silence = yes
denoise = yes
talk_detection_events = yes
dtmf_passthrough = no [admin_user]
type = user
announce_user_count = yes
announce_join_leave = yes
music_on_hold_when_empty = yes
admin = yes
marked = yes
startmuted = no
wait_marked = no
end_marked = no
dsp_drop_silence = yes
denoise = yes
; =============================================================================
; ConfBridge Configuration (Conference Rooms)
; Uses res_timing_timerfd - no DAHDI needed
; ============================================================================= [general] [default_bridge]
type = bridge
max_members = 50
record_conference = no
internal_sample_rate = auto
mixing_interval = 20
video_mode = follow_talker
sound_join = confbridge-join
sound_leave = confbridge-leave
sound_has_joined = confbridge-has-joined
sound_has_left = confbridge-has-left [default_user]
type = user
announce_user_count = yes
announce_user_count_all = yes
announce_join_leave = yes
music_on_hold_when_empty = yes
quiet = no
startmuted = no
wait_marked = no
end_marked = no
dsp_drop_silence = yes
denoise = yes
talk_detection_events = yes
dtmf_passthrough = no [admin_user]
type = user
announce_user_count = yes
announce_join_leave = yes
music_on_hold_when_empty = yes
admin = yes
marked = yes
startmuted = no
wait_marked = no
end_marked = no
dsp_drop_silence = yes
denoise = yes
; =============================================================================
; ConfBridge Configuration (Conference Rooms)
; Uses res_timing_timerfd - no DAHDI needed
; ============================================================================= [general] [default_bridge]
type = bridge
max_members = 50
record_conference = no
internal_sample_rate = auto
mixing_interval = 20
video_mode = follow_talker
sound_join = confbridge-join
sound_leave = confbridge-leave
sound_has_joined = confbridge-has-joined
sound_has_left = confbridge-has-left [default_user]
type = user
announce_user_count = yes
announce_user_count_all = yes
announce_join_leave = yes
music_on_hold_when_empty = yes
quiet = no
startmuted = no
wait_marked = no
end_marked = no
dsp_drop_silence = yes
denoise = yes
talk_detection_events = yes
dtmf_passthrough = no [admin_user]
type = user
announce_user_count = yes
announce_join_leave = yes
music_on_hold_when_empty = yes
admin = yes
marked = yes
startmuted = no
wait_marked = no
end_marked = no
dsp_drop_silence = yes
denoise = yes
SIP Phone (Public) Docker Host Asterisk Container
203.0.113.50 198.51.100.10 172.25.0.2 │ │ │ │◄── SIP INVITE ──────────────►│◄── port forward ────────►│ │ (signaling works fine) │ (5060 mapped) │ │ │ │ │ RTP: "Send audio to │ │ │ 172.25.0.2:15000" │ ← PROBLEM! │ │ Phone can't reach that │ │ │ │ │
SIP Phone (Public) Docker Host Asterisk Container
203.0.113.50 198.51.100.10 172.25.0.2 │ │ │ │◄── SIP INVITE ──────────────►│◄── port forward ────────►│ │ (signaling works fine) │ (5060 mapped) │ │ │ │ │ RTP: "Send audio to │ │ │ 172.25.0.2:15000" │ ← PROBLEM! │ │ Phone can't reach that │ │ │ │ │
SIP Phone (Public) Docker Host Asterisk Container
203.0.113.50 198.51.100.10 172.25.0.2 │ │ │ │◄── SIP INVITE ──────────────►│◄── port forward ────────►│ │ (signaling works fine) │ (5060 mapped) │ │ │ │ │ RTP: "Send audio to │ │ │ 172.25.0.2:15000" │ ← PROBLEM! │ │ Phone can't reach that │ │ │ │ │
; On each transport:
external_media_address = ${PJSIP_EXTERNAL_IP} ; Tell peers to send RTP here
external_signaling_address = ${PJSIP_EXTERNAL_IP} ; Tell peers to send SIP here
local_net = 172.25.0.0/24 ; Docker bridge subnet
local_net = 10.0.0.0/8 ; Other private ranges
local_net = 172.16.0.0/12
local_net = 192.168.0.0/16 ; On each endpoint:
direct_media = no ; Force media through Asterisk (don't try peer-to-peer)
rtp_symmetric = yes ; Send RTP back to the source address we received from
force_rport = yes ; Use the source port from the Contact header
rewrite_contact = yes ; Rewrite Contact to the address we see the peer at
; On each transport:
external_media_address = ${PJSIP_EXTERNAL_IP} ; Tell peers to send RTP here
external_signaling_address = ${PJSIP_EXTERNAL_IP} ; Tell peers to send SIP here
local_net = 172.25.0.0/24 ; Docker bridge subnet
local_net = 10.0.0.0/8 ; Other private ranges
local_net = 172.16.0.0/12
local_net = 192.168.0.0/16 ; On each endpoint:
direct_media = no ; Force media through Asterisk (don't try peer-to-peer)
rtp_symmetric = yes ; Send RTP back to the source address we received from
force_rport = yes ; Use the source port from the Contact header
rewrite_contact = yes ; Rewrite Contact to the address we see the peer at
; On each transport:
external_media_address = ${PJSIP_EXTERNAL_IP} ; Tell peers to send RTP here
external_signaling_address = ${PJSIP_EXTERNAL_IP} ; Tell peers to send SIP here
local_net = 172.25.0.0/24 ; Docker bridge subnet
local_net = 10.0.0.0/8 ; Other private ranges
local_net = 172.16.0.0/12
local_net = 192.168.0.0/16 ; On each endpoint:
direct_media = no ; Force media through Asterisk (don't try peer-to-peer)
rtp_symmetric = yes ; Send RTP back to the source address we received from
force_rport = yes ; Use the source port from the Contact header
rewrite_contact = yes ; Rewrite Contact to the address we see the peer at
# In docker-compose.yml, for the asterisk service:
asterisk: # Remove: networks, ports network_mode: host environment: # PJSIP_EXTERNAL_IP is still needed if behind a cloud NAT/firewall - PJSIP_EXTERNAL_IP=${PJSIP_EXTERNAL_IP}
# In docker-compose.yml, for the asterisk service:
asterisk: # Remove: networks, ports network_mode: host environment: # PJSIP_EXTERNAL_IP is still needed if behind a cloud NAT/firewall - PJSIP_EXTERNAL_IP=${PJSIP_EXTERNAL_IP}
# In docker-compose.yml, for the asterisk service:
asterisk: # Remove: networks, ports network_mode: host environment: # PJSIP_EXTERNAL_IP is still needed if behind a cloud NAT/firewall - PJSIP_EXTERNAL_IP=${PJSIP_EXTERNAL_IP}
networks: asterisk-macvlan: driver: macvlan driver_opts: parent: eth0 # Your host's network interface ipam: config: - subnet: 198.51.100.0/24 gateway: 198.51.100.1 services: asterisk: networks: asterisk-macvlan: ipv4_address: 198.51.100.20 # Unique IP for the container
networks: asterisk-macvlan: driver: macvlan driver_opts: parent: eth0 # Your host's network interface ipam: config: - subnet: 198.51.100.0/24 gateway: 198.51.100.1 services: asterisk: networks: asterisk-macvlan: ipv4_address: 198.51.100.20 # Unique IP for the container
networks: asterisk-macvlan: driver: macvlan driver_opts: parent: eth0 # Your host's network interface ipam: config: - subnet: 198.51.100.0/24 gateway: 198.51.100.1 services: asterisk: networks: asterisk-macvlan: ipv4_address: 198.51.100.20 # Unique IP for the container
; rtp.conf - Smaller port range for fewer calls
[general]
rtpstart = 10000
rtpend = 10200 ; ~100 simultaneous calls
; rtp.conf - Smaller port range for fewer calls
[general]
rtpstart = 10000
rtpend = 10200 ; ~100 simultaneous calls
; rtp.conf - Smaller port range for fewer calls
[general]
rtpstart = 10000
rtpend = 10200 ; ~100 simultaneous calls
ports: - "10000-10200:10000-10200/udp"
ports: - "10000-10200:10000-10200/udp"
ports: - "10000-10200:10000-10200/udp"
Browser (WebRTC) TURN Server (Coturn) Asterisk Container
10.0.0.50 (private) 198.51.100.10:3478 172.25.0.2 │ │ │ │── STUN Binding Req ──►│ │ │◄── Your public IP is │ │ │ 203.0.113.50:45000 │ │ │ │ │ │── TURN Allocate Req ─►│ │ │◄── Relay address: │ │ │ 198.51.100.10:49200│ │ │ │ │ │ ICE Negotiation │ SIP/WebSocket │ │◄──────────────────────►◄────────────────────────►│ │ │ │ │◄── RTP via TURN ──────►◄── RTP ────────────────►│
Browser (WebRTC) TURN Server (Coturn) Asterisk Container
10.0.0.50 (private) 198.51.100.10:3478 172.25.0.2 │ │ │ │── STUN Binding Req ──►│ │ │◄── Your public IP is │ │ │ 203.0.113.50:45000 │ │ │ │ │ │── TURN Allocate Req ─►│ │ │◄── Relay address: │ │ │ 198.51.100.10:49200│ │ │ │ │ │ ICE Negotiation │ SIP/WebSocket │ │◄──────────────────────►◄────────────────────────►│ │ │ │ │◄── RTP via TURN ──────►◄── RTP ────────────────►│
Browser (WebRTC) TURN Server (Coturn) Asterisk Container
10.0.0.50 (private) 198.51.100.10:3478 172.25.0.2 │ │ │ │── STUN Binding Req ──►│ │ │◄── Your public IP is │ │ │ 203.0.113.50:45000 │ │ │ │ │ │── TURN Allocate Req ─►│ │ │◄── Relay address: │ │ │ 198.51.100.10:49200│ │ │ │ │ │ ICE Negotiation │ SIP/WebSocket │ │◄──────────────────────►◄────────────────────────►│ │ │ │ │◄── RTP via TURN ──────►◄── RTP ────────────────►│
stunaddr = stun.l.google.com:19302
icesupport = yes
stunaddr = stun.l.google.com:19302
icesupport = yes
stunaddr = stun.l.google.com:19302
icesupport = yes
# 1. Check RTP ports are actually mapped
docker compose exec asterisk ss -ulnp | grep -E '1[0-9]{4}' # 2. Check what SDP Asterisk is sending (look for the c= line)
docker compose exec asterisk asterisk -rx "pjsip set logger on"
# Make a test call and look for:
# c=IN IP4 198.51.100.10 (should be your public IP)
# NOT: c=IN IP4 172.25.0.2 (container IP = broken) # 3. Capture RTP traffic on the host
tcpdump -i any -n udp portrange 10000-20000 -c 50 # 4. Check if RTP is flowing in both directions
docker compose exec asterisk asterisk -rx "rtp set debug on"
# You should see "Got RTP packet from..." AND "Sent RTP packet to..."
# If you only see one direction, that's your one-way audio # 5. Check NAT settings are applied
docker compose exec asterisk asterisk -rx "pjsip show transport transport-udp"
# Verify external_media_address and external_signaling_address # 6. Use sngrep to see the full SIP exchange
docker compose exec asterisk sngrep
# 1. Check RTP ports are actually mapped
docker compose exec asterisk ss -ulnp | grep -E '1[0-9]{4}' # 2. Check what SDP Asterisk is sending (look for the c= line)
docker compose exec asterisk asterisk -rx "pjsip set logger on"
# Make a test call and look for:
# c=IN IP4 198.51.100.10 (should be your public IP)
# NOT: c=IN IP4 172.25.0.2 (container IP = broken) # 3. Capture RTP traffic on the host
tcpdump -i any -n udp portrange 10000-20000 -c 50 # 4. Check if RTP is flowing in both directions
docker compose exec asterisk asterisk -rx "rtp set debug on"
# You should see "Got RTP packet from..." AND "Sent RTP packet to..."
# If you only see one direction, that's your one-way audio # 5. Check NAT settings are applied
docker compose exec asterisk asterisk -rx "pjsip show transport transport-udp"
# Verify external_media_address and external_signaling_address # 6. Use sngrep to see the full SIP exchange
docker compose exec asterisk sngrep
# 1. Check RTP ports are actually mapped
docker compose exec asterisk ss -ulnp | grep -E '1[0-9]{4}' # 2. Check what SDP Asterisk is sending (look for the c= line)
docker compose exec asterisk asterisk -rx "pjsip set logger on"
# Make a test call and look for:
# c=IN IP4 198.51.100.10 (should be your public IP)
# NOT: c=IN IP4 172.25.0.2 (container IP = broken) # 3. Capture RTP traffic on the host
tcpdump -i any -n udp portrange 10000-20000 -c 50 # 4. Check if RTP is flowing in both directions
docker compose exec asterisk asterisk -rx "rtp set debug on"
# You should see "Got RTP packet from..." AND "Sent RTP packet to..."
# If you only see one direction, that's your one-way audio # 5. Check NAT settings are applied
docker compose exec asterisk asterisk -rx "pjsip show transport transport-udp"
# Verify external_media_address and external_signaling_address # 6. Use sngrep to see the full SIP exchange
docker compose exec asterisk sngrep
Need multiple Asterisk containers on one host? └── YES → Use bridge networking with careful NAT config └── NO ─┐ ├── Have spare public IPs? │ └── YES → Macvlan (cleanest isolation) │ └── NO ──┐ │ ├── WebRTC only (no SIP trunks)? │ │ └── Bridge + TURN server │ └── SIP trunks with real carriers? │ └── Host networking (simplest, proven) └──────────────────────────────────────────────
Need multiple Asterisk containers on one host? └── YES → Use bridge networking with careful NAT config └── NO ─┐ ├── Have spare public IPs? │ └── YES → Macvlan (cleanest isolation) │ └── NO ──┐ │ ├── WebRTC only (no SIP trunks)? │ │ └── Bridge + TURN server │ └── SIP trunks with real carriers? │ └── Host networking (simplest, proven) └──────────────────────────────────────────────
Need multiple Asterisk containers on one host? └── YES → Use bridge networking with careful NAT config └── NO ─┐ ├── Have spare public IPs? │ └── YES → Macvlan (cleanest isolation) │ └── NO ──┐ │ ├── WebRTC only (no SIP trunks)? │ │ └── Bridge + TURN server │ └── SIP trunks with real carriers? │ └── Host networking (simplest, proven) └──────────────────────────────────────────────
# Bind mount - you control the exact path on the host
volumes: recordings: driver: local driver_opts: type: none device: /opt/asterisk-docker/data/recordings o: bind # Named volume - Docker manages the path
volumes: mariadb-data: driver: local
# Bind mount - you control the exact path on the host
volumes: recordings: driver: local driver_opts: type: none device: /opt/asterisk-docker/data/recordings o: bind # Named volume - Docker manages the path
volumes: mariadb-data: driver: local
# Bind mount - you control the exact path on the host
volumes: recordings: driver: local driver_opts: type: none device: /opt/asterisk-docker/data/recordings o: bind # Named volume - Docker manages the path
volumes: mariadb-data: driver: local
# Create recording directory structure
mkdir -p /opt/asterisk-docker/data/recordings # Set up a separate mount point for recordings if you have a dedicated disk
# (recommended for production)
# Example: mount a dedicated partition
# mkfs.ext4 /dev/sdb1
# echo '/dev/sdb1 /opt/asterisk-docker/data/recordings ext4 defaults,noatime 0 2' >> /etc/fstab
# mount /opt/asterisk-docker/data/recordings
# Create recording directory structure
mkdir -p /opt/asterisk-docker/data/recordings # Set up a separate mount point for recordings if you have a dedicated disk
# (recommended for production)
# Example: mount a dedicated partition
# mkfs.ext4 /dev/sdb1
# echo '/dev/sdb1 /opt/asterisk-docker/data/recordings ext4 defaults,noatime 0 2' >> /etc/fstab
# mount /opt/asterisk-docker/data/recordings
# Create recording directory structure
mkdir -p /opt/asterisk-docker/data/recordings # Set up a separate mount point for recordings if you have a dedicated disk
# (recommended for production)
# Example: mount a dedicated partition
# mkfs.ext4 /dev/sdb1
# echo '/dev/sdb1 /opt/asterisk-docker/data/recordings ext4 defaults,noatime 0 2' >> /etc/fstab
# mount /opt/asterisk-docker/data/recordings
#!/bin/bash
# =============================================================================
# Recording Cleanup Script
# Removes recordings older than RETENTION_DAYS
# Run via cron on the Docker host (not inside the container)
# ============================================================================= RECORDING_DIR="/opt/asterisk-docker/data/recordings"
RETENTION_DAYS="${1:-90}" # Default 90 days, override with first argument
LOG_FILE="/var/log/asterisk-recording-cleanup.log" echo "$(date '+%Y-%m-%d %H:%M:%S') Starting recording cleanup (retention: ${RETENTION_DAYS} days)" >> "$LOG_FILE" # Count files before cleanup
BEFORE=$(find "$RECORDING_DIR" -type f -name "*.wav" -o -name "*.WAV" -o -name "*.gsm" -o -name "*.mp3" | wc -l)
BEFORE_SIZE=$(du -sh "$RECORDING_DIR" 2>/dev/null | cut -f1) # Delete old recordings
find "$RECORDING_DIR" -type f \( -name "*.wav" -o -name "*.WAV" -o -name "*.gsm" -o -name "*.mp3" \) \ -mtime +${RETENTION_DAYS} -delete # Delete empty directories left behind
find "$RECORDING_DIR" -type d -empty -delete 2>/dev/null # Count files after cleanup
AFTER=$(find "$RECORDING_DIR" -type f -name "*.wav" -o -name "*.WAV" -o -name "*.gsm" -o -name "*.mp3" | wc -l)
AFTER_SIZE=$(du -sh "$RECORDING_DIR" 2>/dev/null | cut -f1) DELETED=$((BEFORE - AFTER))
echo "$(date '+%Y-%m-%d %H:%M:%S') Cleanup complete: deleted $DELETED files. Before: $BEFORE ($BEFORE_SIZE) After: $AFTER ($AFTER_SIZE)" >> "$LOG_FILE"
#!/bin/bash
# =============================================================================
# Recording Cleanup Script
# Removes recordings older than RETENTION_DAYS
# Run via cron on the Docker host (not inside the container)
# ============================================================================= RECORDING_DIR="/opt/asterisk-docker/data/recordings"
RETENTION_DAYS="${1:-90}" # Default 90 days, override with first argument
LOG_FILE="/var/log/asterisk-recording-cleanup.log" echo "$(date '+%Y-%m-%d %H:%M:%S') Starting recording cleanup (retention: ${RETENTION_DAYS} days)" >> "$LOG_FILE" # Count files before cleanup
BEFORE=$(find "$RECORDING_DIR" -type f -name "*.wav" -o -name "*.WAV" -o -name "*.gsm" -o -name "*.mp3" | wc -l)
BEFORE_SIZE=$(du -sh "$RECORDING_DIR" 2>/dev/null | cut -f1) # Delete old recordings
find "$RECORDING_DIR" -type f \( -name "*.wav" -o -name "*.WAV" -o -name "*.gsm" -o -name "*.mp3" \) \ -mtime +${RETENTION_DAYS} -delete # Delete empty directories left behind
find "$RECORDING_DIR" -type d -empty -delete 2>/dev/null # Count files after cleanup
AFTER=$(find "$RECORDING_DIR" -type f -name "*.wav" -o -name "*.WAV" -o -name "*.gsm" -o -name "*.mp3" | wc -l)
AFTER_SIZE=$(du -sh "$RECORDING_DIR" 2>/dev/null | cut -f1) DELETED=$((BEFORE - AFTER))
echo "$(date '+%Y-%m-%d %H:%M:%S') Cleanup complete: deleted $DELETED files. Before: $BEFORE ($BEFORE_SIZE) After: $AFTER ($AFTER_SIZE)" >> "$LOG_FILE"
#!/bin/bash
# =============================================================================
# Recording Cleanup Script
# Removes recordings older than RETENTION_DAYS
# Run via cron on the Docker host (not inside the container)
# ============================================================================= RECORDING_DIR="/opt/asterisk-docker/data/recordings"
RETENTION_DAYS="${1:-90}" # Default 90 days, override with first argument
LOG_FILE="/var/log/asterisk-recording-cleanup.log" echo "$(date '+%Y-%m-%d %H:%M:%S') Starting recording cleanup (retention: ${RETENTION_DAYS} days)" >> "$LOG_FILE" # Count files before cleanup
BEFORE=$(find "$RECORDING_DIR" -type f -name "*.wav" -o -name "*.WAV" -o -name "*.gsm" -o -name "*.mp3" | wc -l)
BEFORE_SIZE=$(du -sh "$RECORDING_DIR" 2>/dev/null | cut -f1) # Delete old recordings
find "$RECORDING_DIR" -type f \( -name "*.wav" -o -name "*.WAV" -o -name "*.gsm" -o -name "*.mp3" \) \ -mtime +${RETENTION_DAYS} -delete # Delete empty directories left behind
find "$RECORDING_DIR" -type d -empty -delete 2>/dev/null # Count files after cleanup
AFTER=$(find "$RECORDING_DIR" -type f -name "*.wav" -o -name "*.WAV" -o -name "*.gsm" -o -name "*.mp3" | wc -l)
AFTER_SIZE=$(du -sh "$RECORDING_DIR" 2>/dev/null | cut -f1) DELETED=$((BEFORE - AFTER))
echo "$(date '+%Y-%m-%d %H:%M:%S') Cleanup complete: deleted $DELETED files. Before: $BEFORE ($BEFORE_SIZE) After: $AFTER ($AFTER_SIZE)" >> "$LOG_FILE"
# Make executable and add to cron
chmod +x /opt/asterisk-docker/scripts/cleanup-recordings.sh # Run daily at 3 AM, keep 90 days
(crontab -l 2>/dev/null; echo "0 3 * * * /opt/asterisk-docker/scripts/cleanup-recordings.sh 90") | crontab -
# Make executable and add to cron
chmod +x /opt/asterisk-docker/scripts/cleanup-recordings.sh # Run daily at 3 AM, keep 90 days
(crontab -l 2>/dev/null; echo "0 3 * * * /opt/asterisk-docker/scripts/cleanup-recordings.sh 90") | crontab -
# Make executable and add to cron
chmod +x /opt/asterisk-docker/scripts/cleanup-recordings.sh # Run daily at 3 AM, keep 90 days
(crontab -l 2>/dev/null; echo "0 3 * * * /opt/asterisk-docker/scripts/cleanup-recordings.sh 90") | crontab -
/opt/asterisk-docker/data/logs/messages
/opt/asterisk-docker/data/logs/full
/opt/asterisk-docker/data/logs/error
/opt/asterisk-docker/data/logs/verbose
{ daily rotate 7 compress delaycompress missingok notifempty create 0640 1000 1000 postrotate docker exec asterisk asterisk -rx "logger reload" 2>/dev/null || true endscript
} /opt/asterisk-docker/data/logs/cdr-csv/*.csv
{ monthly rotate 12 compress delaycompress missingok notifempty create 0640 1000 1000
}
/opt/asterisk-docker/data/logs/messages
/opt/asterisk-docker/data/logs/full
/opt/asterisk-docker/data/logs/error
/opt/asterisk-docker/data/logs/verbose
{ daily rotate 7 compress delaycompress missingok notifempty create 0640 1000 1000 postrotate docker exec asterisk asterisk -rx "logger reload" 2>/dev/null || true endscript
} /opt/asterisk-docker/data/logs/cdr-csv/*.csv
{ monthly rotate 12 compress delaycompress missingok notifempty create 0640 1000 1000
}
/opt/asterisk-docker/data/logs/messages
/opt/asterisk-docker/data/logs/full
/opt/asterisk-docker/data/logs/error
/opt/asterisk-docker/data/logs/verbose
{ daily rotate 7 compress delaycompress missingok notifempty create 0640 1000 1000 postrotate docker exec asterisk asterisk -rx "logger reload" 2>/dev/null || true endscript
} /opt/asterisk-docker/data/logs/cdr-csv/*.csv
{ monthly rotate 12 compress delaycompress missingok notifempty create 0640 1000 1000
}
# Install logrotate config
cp /opt/asterisk-docker/scripts/logrotate-asterisk.conf /etc/logrotate.d/asterisk-docker
# Install logrotate config
cp /opt/asterisk-docker/scripts/logrotate-asterisk.conf /etc/logrotate.d/asterisk-docker
# Install logrotate config
cp /opt/asterisk-docker/scripts/logrotate-asterisk.conf /etc/logrotate.d/asterisk-docker
#!/bin/bash
# =============================================================================
# Asterisk Docker Stack - Full Backup
# Backs up: MariaDB dump, recordings, voicemail, configs, certificates
# ============================================================================= set -e BACKUP_DIR="/opt/asterisk-docker/backups"
DATE=$(date '+%Y%m%d_%H%M%S')
BACKUP_PATH="$BACKUP_DIR/$DATE"
RETENTION_DAYS=30 # Source environment
set -a
source /opt/asterisk-docker/.env
set +a echo "=== Asterisk Backup Starting: $DATE ===" mkdir -p "$BACKUP_PATH" # ---- 1. Database dump ----
echo "Backing up MariaDB..."
docker exec asterisk-mariadb mysqldump \ -u root -p"${DB_ROOT_PASSWORD}" \ --all-databases \ --single-transaction \ --routines \ --triggers \ --events \ > "$BACKUP_PATH/mariadb-all-databases.sql"
echo " Database dump: $(du -sh "$BACKUP_PATH/mariadb-all-databases.sql" | cut -f1)" # ---- 2. Configuration files ----
echo "Backing up configuration..."
tar czf "$BACKUP_PATH/configs.tar.gz" \ -C /opt/asterisk-docker \ asterisk/configs/ \ nginx/ \ coturn/ \ docker-compose.yml \ .env # ---- 3. Voicemail ----
echo "Backing up voicemail..."
if [ -d "/opt/asterisk-docker/data/voicemail" ] && [ "$(ls -A /opt/asterisk-docker/data/voicemail 2>/dev/null)" ]; then tar czf "$BACKUP_PATH/voicemail.tar.gz" \ -C /opt/asterisk-docker/data voicemail/ echo " Voicemail: $(du -sh "$BACKUP_PATH/voicemail.tar.gz" | cut -f1)"
else echo " Voicemail: empty, skipped"
fi # ---- 4. Certificates ----
echo "Backing up certificates..."
if [ -d "/opt/asterisk-docker/data/certs" ]; then tar czf "$BACKUP_PATH/certs.tar.gz" \ -C /opt/asterisk-docker/data certs/
fi # ---- 5. Recordings (optional - can be very large) ----
# Uncomment to include recordings in backup
# echo "Backing up recordings..."
# tar czf "$BACKUP_PATH/recordings.tar.gz" \
# -C /opt/asterisk-docker/data recordings/ # ---- 6. Create manifest ----
echo "Creating backup manifest..."
cat > "$BACKUP_PATH/manifest.txt" <<MANIFEST
Asterisk Docker Backup
Date: $(date)
Host: $(hostname)
Docker Compose Project: ${COMPOSE_PROJECT_NAME} Files:
$(ls -lh "$BACKUP_PATH/") Container Status:
$(docker compose -f /opt/asterisk-docker/docker-compose.yml ps 2>/dev/null) Asterisk Version:
$(docker exec asterisk asterisk -V 2>/dev/null || echo "not running") Database Size:
$(docker exec asterisk-mariadb mysql -u root -p"${DB_ROOT_PASSWORD}" -e "SELECT table_schema AS db, ROUND(SUM(data_length + index_length) / 1024 / 1024, 2) AS 'Size (MB)' FROM information_schema.tables GROUP BY table_schema;" 2>/dev/null || echo "not available")
MANIFEST # ---- 7. Compress entire backup ----
echo "Compressing backup..."
tar czf "$BACKUP_DIR/asterisk-backup-$DATE.tar.gz" -C "$BACKUP_DIR" "$DATE/"
rm -rf "$BACKUP_PATH"
echo "Final backup: $(du -sh "$BACKUP_DIR/asterisk-backup-$DATE.tar.gz" | cut -f1)" # ---- 8. Cleanup old backups ----
echo "Cleaning up backups older than $RETENTION_DAYS days..."
find "$BACKUP_DIR" -name "asterisk-backup-*.tar.gz" -mtime +${RETENTION_DAYS} -delete echo "=== Backup Complete ==="
#!/bin/bash
# =============================================================================
# Asterisk Docker Stack - Full Backup
# Backs up: MariaDB dump, recordings, voicemail, configs, certificates
# ============================================================================= set -e BACKUP_DIR="/opt/asterisk-docker/backups"
DATE=$(date '+%Y%m%d_%H%M%S')
BACKUP_PATH="$BACKUP_DIR/$DATE"
RETENTION_DAYS=30 # Source environment
set -a
source /opt/asterisk-docker/.env
set +a echo "=== Asterisk Backup Starting: $DATE ===" mkdir -p "$BACKUP_PATH" # ---- 1. Database dump ----
echo "Backing up MariaDB..."
docker exec asterisk-mariadb mysqldump \ -u root -p"${DB_ROOT_PASSWORD}" \ --all-databases \ --single-transaction \ --routines \ --triggers \ --events \ > "$BACKUP_PATH/mariadb-all-databases.sql"
echo " Database dump: $(du -sh "$BACKUP_PATH/mariadb-all-databases.sql" | cut -f1)" # ---- 2. Configuration files ----
echo "Backing up configuration..."
tar czf "$BACKUP_PATH/configs.tar.gz" \ -C /opt/asterisk-docker \ asterisk/configs/ \ nginx/ \ coturn/ \ docker-compose.yml \ .env # ---- 3. Voicemail ----
echo "Backing up voicemail..."
if [ -d "/opt/asterisk-docker/data/voicemail" ] && [ "$(ls -A /opt/asterisk-docker/data/voicemail 2>/dev/null)" ]; then tar czf "$BACKUP_PATH/voicemail.tar.gz" \ -C /opt/asterisk-docker/data voicemail/ echo " Voicemail: $(du -sh "$BACKUP_PATH/voicemail.tar.gz" | cut -f1)"
else echo " Voicemail: empty, skipped"
fi # ---- 4. Certificates ----
echo "Backing up certificates..."
if [ -d "/opt/asterisk-docker/data/certs" ]; then tar czf "$BACKUP_PATH/certs.tar.gz" \ -C /opt/asterisk-docker/data certs/
fi # ---- 5. Recordings (optional - can be very large) ----
# Uncomment to include recordings in backup
# echo "Backing up recordings..."
# tar czf "$BACKUP_PATH/recordings.tar.gz" \
# -C /opt/asterisk-docker/data recordings/ # ---- 6. Create manifest ----
echo "Creating backup manifest..."
cat > "$BACKUP_PATH/manifest.txt" <<MANIFEST
Asterisk Docker Backup
Date: $(date)
Host: $(hostname)
Docker Compose Project: ${COMPOSE_PROJECT_NAME} Files:
$(ls -lh "$BACKUP_PATH/") Container Status:
$(docker compose -f /opt/asterisk-docker/docker-compose.yml ps 2>/dev/null) Asterisk Version:
$(docker exec asterisk asterisk -V 2>/dev/null || echo "not running") Database Size:
$(docker exec asterisk-mariadb mysql -u root -p"${DB_ROOT_PASSWORD}" -e "SELECT table_schema AS db, ROUND(SUM(data_length + index_length) / 1024 / 1024, 2) AS 'Size (MB)' FROM information_schema.tables GROUP BY table_schema;" 2>/dev/null || echo "not available")
MANIFEST # ---- 7. Compress entire backup ----
echo "Compressing backup..."
tar czf "$BACKUP_DIR/asterisk-backup-$DATE.tar.gz" -C "$BACKUP_DIR" "$DATE/"
rm -rf "$BACKUP_PATH"
echo "Final backup: $(du -sh "$BACKUP_DIR/asterisk-backup-$DATE.tar.gz" | cut -f1)" # ---- 8. Cleanup old backups ----
echo "Cleaning up backups older than $RETENTION_DAYS days..."
find "$BACKUP_DIR" -name "asterisk-backup-*.tar.gz" -mtime +${RETENTION_DAYS} -delete echo "=== Backup Complete ==="
#!/bin/bash
# =============================================================================
# Asterisk Docker Stack - Full Backup
# Backs up: MariaDB dump, recordings, voicemail, configs, certificates
# ============================================================================= set -e BACKUP_DIR="/opt/asterisk-docker/backups"
DATE=$(date '+%Y%m%d_%H%M%S')
BACKUP_PATH="$BACKUP_DIR/$DATE"
RETENTION_DAYS=30 # Source environment
set -a
source /opt/asterisk-docker/.env
set +a echo "=== Asterisk Backup Starting: $DATE ===" mkdir -p "$BACKUP_PATH" # ---- 1. Database dump ----
echo "Backing up MariaDB..."
docker exec asterisk-mariadb mysqldump \ -u root -p"${DB_ROOT_PASSWORD}" \ --all-databases \ --single-transaction \ --routines \ --triggers \ --events \ > "$BACKUP_PATH/mariadb-all-databases.sql"
echo " Database dump: $(du -sh "$BACKUP_PATH/mariadb-all-databases.sql" | cut -f1)" # ---- 2. Configuration files ----
echo "Backing up configuration..."
tar czf "$BACKUP_PATH/configs.tar.gz" \ -C /opt/asterisk-docker \ asterisk/configs/ \ nginx/ \ coturn/ \ docker-compose.yml \ .env # ---- 3. Voicemail ----
echo "Backing up voicemail..."
if [ -d "/opt/asterisk-docker/data/voicemail" ] && [ "$(ls -A /opt/asterisk-docker/data/voicemail 2>/dev/null)" ]; then tar czf "$BACKUP_PATH/voicemail.tar.gz" \ -C /opt/asterisk-docker/data voicemail/ echo " Voicemail: $(du -sh "$BACKUP_PATH/voicemail.tar.gz" | cut -f1)"
else echo " Voicemail: empty, skipped"
fi # ---- 4. Certificates ----
echo "Backing up certificates..."
if [ -d "/opt/asterisk-docker/data/certs" ]; then tar czf "$BACKUP_PATH/certs.tar.gz" \ -C /opt/asterisk-docker/data certs/
fi # ---- 5. Recordings (optional - can be very large) ----
# Uncomment to include recordings in backup
# echo "Backing up recordings..."
# tar czf "$BACKUP_PATH/recordings.tar.gz" \
# -C /opt/asterisk-docker/data recordings/ # ---- 6. Create manifest ----
echo "Creating backup manifest..."
cat > "$BACKUP_PATH/manifest.txt" <<MANIFEST
Asterisk Docker Backup
Date: $(date)
Host: $(hostname)
Docker Compose Project: ${COMPOSE_PROJECT_NAME} Files:
$(ls -lh "$BACKUP_PATH/") Container Status:
$(docker compose -f /opt/asterisk-docker/docker-compose.yml ps 2>/dev/null) Asterisk Version:
$(docker exec asterisk asterisk -V 2>/dev/null || echo "not running") Database Size:
$(docker exec asterisk-mariadb mysql -u root -p"${DB_ROOT_PASSWORD}" -e "SELECT table_schema AS db, ROUND(SUM(data_length + index_length) / 1024 / 1024, 2) AS 'Size (MB)' FROM information_schema.tables GROUP BY table_schema;" 2>/dev/null || echo "not available")
MANIFEST # ---- 7. Compress entire backup ----
echo "Compressing backup..."
tar czf "$BACKUP_DIR/asterisk-backup-$DATE.tar.gz" -C "$BACKUP_DIR" "$DATE/"
rm -rf "$BACKUP_PATH"
echo "Final backup: $(du -sh "$BACKUP_DIR/asterisk-backup-$DATE.tar.gz" | cut -f1)" # ---- 8. Cleanup old backups ----
echo "Cleaning up backups older than $RETENTION_DAYS days..."
find "$BACKUP_DIR" -name "asterisk-backup-*.tar.gz" -mtime +${RETENTION_DAYS} -delete echo "=== Backup Complete ==="
chmod +x /opt/asterisk-docker/scripts/backup.sh # Run backup daily at 2 AM
(crontab -l 2>/dev/null; echo "0 2 * * * /opt/asterisk-docker/scripts/backup.sh >> /var/log/asterisk-backup.log 2>&1") | crontab -
chmod +x /opt/asterisk-docker/scripts/backup.sh # Run backup daily at 2 AM
(crontab -l 2>/dev/null; echo "0 2 * * * /opt/asterisk-docker/scripts/backup.sh >> /var/log/asterisk-backup.log 2>&1") | crontab -
chmod +x /opt/asterisk-docker/scripts/backup.sh # Run backup daily at 2 AM
(crontab -l 2>/dev/null; echo "0 2 * * * /opt/asterisk-docker/scripts/backup.sh >> /var/log/asterisk-backup.log 2>&1") | crontab -
#!/bin/bash
# =============================================================================
# Asterisk Docker Stack - Restore from Backup
# Usage: ./restore.sh /path/to/asterisk-backup-YYYYMMDD_HHMMSS.tar.gz
# ============================================================================= set -e BACKUP_FILE="$1"
RESTORE_DIR="/tmp/asterisk-restore-$$" if [ -z "$BACKUP_FILE" ] || [ ! -f "$BACKUP_FILE" ]; then echo "Usage: $0 /path/to/asterisk-backup-*.tar.gz" exit 1
fi # Source environment
set -a
source /opt/asterisk-docker/.env
set +a echo "=== Asterisk Restore Starting ==="
echo "Backup file: $BACKUP_FILE"
echo ""
echo "WARNING: This will overwrite current data. Press Ctrl+C to abort."
echo "Continuing in 10 seconds..."
sleep 10 # Extract backup
mkdir -p "$RESTORE_DIR"
tar xzf "$BACKUP_FILE" -C "$RESTORE_DIR"
BACKUP_DATE=$(ls "$RESTORE_DIR")
BACKUP_PATH="$RESTORE_DIR/$BACKUP_DATE" echo "Backup date: $BACKUP_DATE" # Stop services
echo "Stopping services..."
cd /opt/asterisk-docker
docker compose down # Restore database
if [ -f "$BACKUP_PATH/mariadb-all-databases.sql" ]; then echo "Restoring database..." docker compose up -d mariadb sleep 10 # Wait for MariaDB to be ready docker exec -i asterisk-mariadb mysql -u root -p"${DB_ROOT_PASSWORD}" \ < "$BACKUP_PATH/mariadb-all-databases.sql" echo " Database restored."
fi # Restore configs
if [ -f "$BACKUP_PATH/configs.tar.gz" ]; then echo "Restoring configurations..." tar xzf "$BACKUP_PATH/configs.tar.gz" -C /opt/asterisk-docker/ echo " Configs restored."
fi # Restore voicemail
if [ -f "$BACKUP_PATH/voicemail.tar.gz" ]; then echo "Restoring voicemail..." tar xzf "$BACKUP_PATH/voicemail.tar.gz" -C /opt/asterisk-docker/data/ echo " Voicemail restored."
fi # Restore certificates
if [ -f "$BACKUP_PATH/certs.tar.gz" ]; then echo "Restoring certificates..." tar xzf "$BACKUP_PATH/certs.tar.gz" -C /opt/asterisk-docker/data/ echo " Certificates restored."
fi # Start all services
echo "Starting all services..."
docker compose up -d # Cleanup
rm -rf "$RESTORE_DIR" echo "=== Restore Complete ==="
echo "Verify with: docker compose ps"
#!/bin/bash
# =============================================================================
# Asterisk Docker Stack - Restore from Backup
# Usage: ./restore.sh /path/to/asterisk-backup-YYYYMMDD_HHMMSS.tar.gz
# ============================================================================= set -e BACKUP_FILE="$1"
RESTORE_DIR="/tmp/asterisk-restore-$$" if [ -z "$BACKUP_FILE" ] || [ ! -f "$BACKUP_FILE" ]; then echo "Usage: $0 /path/to/asterisk-backup-*.tar.gz" exit 1
fi # Source environment
set -a
source /opt/asterisk-docker/.env
set +a echo "=== Asterisk Restore Starting ==="
echo "Backup file: $BACKUP_FILE"
echo ""
echo "WARNING: This will overwrite current data. Press Ctrl+C to abort."
echo "Continuing in 10 seconds..."
sleep 10 # Extract backup
mkdir -p "$RESTORE_DIR"
tar xzf "$BACKUP_FILE" -C "$RESTORE_DIR"
BACKUP_DATE=$(ls "$RESTORE_DIR")
BACKUP_PATH="$RESTORE_DIR/$BACKUP_DATE" echo "Backup date: $BACKUP_DATE" # Stop services
echo "Stopping services..."
cd /opt/asterisk-docker
docker compose down # Restore database
if [ -f "$BACKUP_PATH/mariadb-all-databases.sql" ]; then echo "Restoring database..." docker compose up -d mariadb sleep 10 # Wait for MariaDB to be ready docker exec -i asterisk-mariadb mysql -u root -p"${DB_ROOT_PASSWORD}" \ < "$BACKUP_PATH/mariadb-all-databases.sql" echo " Database restored."
fi # Restore configs
if [ -f "$BACKUP_PATH/configs.tar.gz" ]; then echo "Restoring configurations..." tar xzf "$BACKUP_PATH/configs.tar.gz" -C /opt/asterisk-docker/ echo " Configs restored."
fi # Restore voicemail
if [ -f "$BACKUP_PATH/voicemail.tar.gz" ]; then echo "Restoring voicemail..." tar xzf "$BACKUP_PATH/voicemail.tar.gz" -C /opt/asterisk-docker/data/ echo " Voicemail restored."
fi # Restore certificates
if [ -f "$BACKUP_PATH/certs.tar.gz" ]; then echo "Restoring certificates..." tar xzf "$BACKUP_PATH/certs.tar.gz" -C /opt/asterisk-docker/data/ echo " Certificates restored."
fi # Start all services
echo "Starting all services..."
docker compose up -d # Cleanup
rm -rf "$RESTORE_DIR" echo "=== Restore Complete ==="
echo "Verify with: docker compose ps"
#!/bin/bash
# =============================================================================
# Asterisk Docker Stack - Restore from Backup
# Usage: ./restore.sh /path/to/asterisk-backup-YYYYMMDD_HHMMSS.tar.gz
# ============================================================================= set -e BACKUP_FILE="$1"
RESTORE_DIR="/tmp/asterisk-restore-$$" if [ -z "$BACKUP_FILE" ] || [ ! -f "$BACKUP_FILE" ]; then echo "Usage: $0 /path/to/asterisk-backup-*.tar.gz" exit 1
fi # Source environment
set -a
source /opt/asterisk-docker/.env
set +a echo "=== Asterisk Restore Starting ==="
echo "Backup file: $BACKUP_FILE"
echo ""
echo "WARNING: This will overwrite current data. Press Ctrl+C to abort."
echo "Continuing in 10 seconds..."
sleep 10 # Extract backup
mkdir -p "$RESTORE_DIR"
tar xzf "$BACKUP_FILE" -C "$RESTORE_DIR"
BACKUP_DATE=$(ls "$RESTORE_DIR")
BACKUP_PATH="$RESTORE_DIR/$BACKUP_DATE" echo "Backup date: $BACKUP_DATE" # Stop services
echo "Stopping services..."
cd /opt/asterisk-docker
docker compose down # Restore database
if [ -f "$BACKUP_PATH/mariadb-all-databases.sql" ]; then echo "Restoring database..." docker compose up -d mariadb sleep 10 # Wait for MariaDB to be ready docker exec -i asterisk-mariadb mysql -u root -p"${DB_ROOT_PASSWORD}" \ < "$BACKUP_PATH/mariadb-all-databases.sql" echo " Database restored."
fi # Restore configs
if [ -f "$BACKUP_PATH/configs.tar.gz" ]; then echo "Restoring configurations..." tar xzf "$BACKUP_PATH/configs.tar.gz" -C /opt/asterisk-docker/ echo " Configs restored."
fi # Restore voicemail
if [ -f "$BACKUP_PATH/voicemail.tar.gz" ]; then echo "Restoring voicemail..." tar xzf "$BACKUP_PATH/voicemail.tar.gz" -C /opt/asterisk-docker/data/ echo " Voicemail restored."
fi # Restore certificates
if [ -f "$BACKUP_PATH/certs.tar.gz" ]; then echo "Restoring certificates..." tar xzf "$BACKUP_PATH/certs.tar.gz" -C /opt/asterisk-docker/data/ echo " Certificates restored."
fi # Start all services
echo "Starting all services..."
docker compose up -d # Cleanup
rm -rf "$RESTORE_DIR" echo "=== Restore Complete ==="
echo "Verify with: docker compose ps"
chmod +x /opt/asterisk-docker/scripts/restore.sh
chmod +x /opt/asterisk-docker/scripts/restore.sh
chmod +x /opt/asterisk-docker/scripts/restore.sh
-- =============================================================================
-- Asterisk Database Schema
-- Executed automatically on first MariaDB container start
-- ============================================================================= USE asterisk; -- ---- CDR Table ----
CREATE TABLE IF NOT EXISTS cdr ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, calldate DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00', clid VARCHAR(80) NOT NULL DEFAULT '', src VARCHAR(80) NOT NULL DEFAULT '', dst VARCHAR(80) NOT NULL DEFAULT '', dcontext VARCHAR(80) NOT NULL DEFAULT '', channel VARCHAR(80) NOT NULL DEFAULT '', dstchannel VARCHAR(80) NOT NULL DEFAULT '', lastapp VARCHAR(80) NOT NULL DEFAULT '', lastdata VARCHAR(80) NOT NULL DEFAULT '', duration INT NOT NULL DEFAULT 0, billsec INT NOT NULL DEFAULT 0, disposition VARCHAR(45) NOT NULL DEFAULT '', amaflags INT NOT NULL DEFAULT 0, accountcode VARCHAR(20) NOT NULL DEFAULT '', uniqueid VARCHAR(150) NOT NULL DEFAULT '', userfield VARCHAR(255) NOT NULL DEFAULT '', peeraccount VARCHAR(20) NOT NULL DEFAULT '', linkedid VARCHAR(150) NOT NULL DEFAULT '', sequence INT NOT NULL DEFAULT 0, PRIMARY KEY (id), INDEX idx_calldate (calldate), INDEX idx_dst (dst), INDEX idx_src (src), INDEX idx_uniqueid (uniqueid), INDEX idx_disposition (disposition), INDEX idx_accountcode (accountcode), INDEX idx_clid (clid(20))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- ---- PJSIP Realtime Tables ----
-- These allow you to manage SIP endpoints via database instead of config files -- Endpoints
CREATE TABLE IF NOT EXISTS ps_endpoints ( id VARCHAR(40) NOT NULL, transport VARCHAR(40) DEFAULT NULL, aors VARCHAR(200) DEFAULT NULL, auth VARCHAR(40) DEFAULT NULL, context VARCHAR(40) DEFAULT 'from-internal', disallow VARCHAR(200) DEFAULT 'all', allow VARCHAR(200) DEFAULT 'opus,g722,ulaw,alaw', direct_media ENUM('yes','no') DEFAULT 'no', connected_line_method VARCHAR(40) DEFAULT NULL, direct_media_method VARCHAR(40) DEFAULT NULL, direct_media_glare_mitigation VARCHAR(40) DEFAULT NULL, disable_direct_media_on_nat ENUM('yes','no') DEFAULT NULL, dtmf_mode VARCHAR(40) DEFAULT 'rfc4733', external_media_address VARCHAR(40) DEFAULT NULL, force_rport ENUM('yes','no') DEFAULT 'yes', ice_support ENUM('yes','no') DEFAULT 'no', identify_by VARCHAR(80) DEFAULT NULL, mailboxes VARCHAR(40) DEFAULT NULL, media_address VARCHAR(40) DEFAULT NULL, media_encryption VARCHAR(40) DEFAULT 'no', media_use_received_transport ENUM('yes','no') DEFAULT NULL, 100rel VARCHAR(40) DEFAULT NULL, outbound_auth VARCHAR(40) DEFAULT NULL, outbound_proxy VARCHAR(256) DEFAULT NULL, rewrite_contact ENUM('yes','no') DEFAULT 'yes', rtp_ipv6 ENUM('yes','no') DEFAULT NULL, rtp_symmetric ENUM('yes','no') DEFAULT 'yes', send_diversion ENUM('yes','no') DEFAULT NULL, send_pai ENUM('yes','no') DEFAULT 'yes', send_rpid ENUM('yes','no') DEFAULT 'yes', timers_min_se INT DEFAULT NULL, timers VARCHAR(40) DEFAULT NULL, timers_sess_expires INT DEFAULT NULL, callerid VARCHAR(40) DEFAULT NULL, callerid_privacy VARCHAR(40) DEFAULT NULL, callerid_tag VARCHAR(40) DEFAULT NULL, trust_id_inbound ENUM('yes','no') DEFAULT 'yes', trust_id_outbound ENUM('yes','no') DEFAULT NULL, use_ptime ENUM('yes','no') DEFAULT NULL, use_avpf ENUM('yes','no') DEFAULT NULL, force_avp ENUM('yes','no') DEFAULT NULL, media_encryption_optimistic ENUM('yes','no') DEFAULT NULL, inband_progress ENUM('yes','no') DEFAULT NULL, call_group VARCHAR(40) DEFAULT NULL, pickup_group VARCHAR(40) DEFAULT NULL, named_call_group VARCHAR(40) DEFAULT NULL, named_pickup_group VARCHAR(40) DEFAULT NULL, device_state_busy_at INT DEFAULT NULL, t38_udptl ENUM('yes','no') DEFAULT NULL, t38_udptl_ec VARCHAR(40) DEFAULT NULL, t38_udptl_maxdatagram INT DEFAULT NULL, fax_detect ENUM('yes','no') DEFAULT NULL, t38_udptl_nat ENUM('yes','no') DEFAULT NULL, t38_udptl_ipv6 ENUM('yes','no') DEFAULT NULL, tone_zone VARCHAR(40) DEFAULT NULL, language VARCHAR(40) DEFAULT NULL, one_touch_recording ENUM('yes','no') DEFAULT NULL, record_on_feature VARCHAR(40) DEFAULT NULL, record_off_feature VARCHAR(40) DEFAULT NULL, rtp_engine VARCHAR(40) DEFAULT NULL, allow_transfer ENUM('yes','no') DEFAULT NULL, allow_subscribe ENUM('yes','no') DEFAULT NULL, sdp_owner VARCHAR(40) DEFAULT NULL, sdp_session VARCHAR(40) DEFAULT NULL, tos_audio VARCHAR(10) DEFAULT NULL, tos_video VARCHAR(10) DEFAULT NULL, sub_min_expiry INT DEFAULT NULL, from_domain VARCHAR(40) DEFAULT NULL, from_user VARCHAR(40) DEFAULT NULL, mwi_from_user VARCHAR(40) DEFAULT NULL, dtls_verify VARCHAR(40) DEFAULT NULL, dtls_rekey VARCHAR(40) DEFAULT NULL, dtls_cert_file VARCHAR(200) DEFAULT NULL, dtls_private_key VARCHAR(200) DEFAULT NULL, dtls_cipher VARCHAR(200) DEFAULT NULL, dtls_ca_file VARCHAR(200) DEFAULT NULL, dtls_ca_path VARCHAR(200) DEFAULT NULL, dtls_setup VARCHAR(40) DEFAULT NULL, srtp_tag_32 ENUM('yes','no') DEFAULT NULL, webrtc ENUM('yes','no') DEFAULT 'no', PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Authentication
CREATE TABLE IF NOT EXISTS ps_auths ( id VARCHAR(40) NOT NULL, auth_type VARCHAR(40) DEFAULT 'userpass', nonce_lifetime INT DEFAULT NULL, md5_cred VARCHAR(40) DEFAULT NULL, password VARCHAR(80) DEFAULT NULL, realm VARCHAR(40) DEFAULT NULL, username VARCHAR(40) DEFAULT NULL, PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- AORs (Address of Record)
CREATE TABLE IF NOT EXISTS ps_aors ( id VARCHAR(40) NOT NULL, contact VARCHAR(255) DEFAULT NULL, default_expiration INT DEFAULT 300, mailboxes VARCHAR(80) DEFAULT NULL, max_contacts INT DEFAULT 3, minimum_expiration INT DEFAULT 60, remove_existing ENUM('yes','no') DEFAULT 'yes', qualify_frequency INT DEFAULT 60, qualify_timeout FLOAT DEFAULT 3.0, authenticate_qualify ENUM('yes','no') DEFAULT NULL, outbound_proxy VARCHAR(256) DEFAULT NULL, support_path ENUM('yes','no') DEFAULT NULL, PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Contacts (registered devices)
CREATE TABLE IF NOT EXISTS ps_contacts ( id VARCHAR(255) NOT NULL, uri VARCHAR(255) DEFAULT NULL, expiration_time BIGINT DEFAULT NULL, qualify_frequency INT DEFAULT NULL, outbound_proxy VARCHAR(256) DEFAULT NULL, path TEXT DEFAULT NULL, user_agent VARCHAR(255) DEFAULT NULL, qualify_timeout FLOAT DEFAULT NULL, reg_server VARCHAR(20) DEFAULT NULL, authenticate_qualify ENUM('yes','no') DEFAULT NULL, via_addr VARCHAR(40) DEFAULT NULL, via_port INT DEFAULT NULL, call_id VARCHAR(255) DEFAULT NULL, endpoint VARCHAR(40) DEFAULT NULL, prune_on_boot ENUM('yes','no') DEFAULT 'yes', PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Endpoint Identification by IP
CREATE TABLE IF NOT EXISTS ps_endpoint_id_ips ( id VARCHAR(40) NOT NULL, endpoint VARCHAR(40) DEFAULT NULL, match VARCHAR(80) DEFAULT NULL, srv_lookups ENUM('yes','no') DEFAULT NULL, match_header VARCHAR(255) DEFAULT NULL, PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Domain Aliases
CREATE TABLE IF NOT EXISTS ps_domain_aliases ( id VARCHAR(40) NOT NULL, domain VARCHAR(80) DEFAULT NULL, PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Registrations (outbound registration to SIP trunks)
CREATE TABLE IF NOT EXISTS ps_registrations ( id VARCHAR(40) NOT NULL, auth_rejection_permanent ENUM('yes','no') DEFAULT NULL, client_uri VARCHAR(255) DEFAULT NULL, contact_user VARCHAR(40) DEFAULT NULL, expiration INT DEFAULT NULL, max_retries INT DEFAULT NULL, outbound_auth VARCHAR(40) DEFAULT NULL, outbound_proxy VARCHAR(256) DEFAULT NULL, retry_interval INT DEFAULT NULL, forbidden_retry_interval INT DEFAULT NULL, server_uri VARCHAR(255) DEFAULT NULL, transport VARCHAR(40) DEFAULT NULL, support_path ENUM('yes','no') DEFAULT NULL, line ENUM('yes','no') DEFAULT NULL, endpoint VARCHAR(40) DEFAULT NULL, PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- ---- Voicemail Users Table ----
CREATE TABLE IF NOT EXISTS voicemail_users ( uniqueid INT UNSIGNED NOT NULL AUTO_INCREMENT, context VARCHAR(50) NOT NULL DEFAULT 'default', mailbox VARCHAR(20) NOT NULL, password VARCHAR(20) NOT NULL DEFAULT '1234', fullname VARCHAR(150) DEFAULT NULL, email VARCHAR(250) DEFAULT NULL, pager VARCHAR(250) DEFAULT NULL, tz VARCHAR(80) DEFAULT 'central', attach ENUM('yes','no') DEFAULT 'yes', saycid ENUM('yes','no') DEFAULT 'yes', dialout VARCHAR(10) DEFAULT NULL, callback VARCHAR(10) DEFAULT NULL, review ENUM('yes','no') DEFAULT 'no', operator ENUM('yes','no') DEFAULT 'no', envelope ENUM('yes','no') DEFAULT 'no', sayduration ENUM('yes','no') DEFAULT 'yes', saydurationm INT DEFAULT 1, sendvoicemail ENUM('yes','no') DEFAULT 'no', delete_vm ENUM('yes','no') DEFAULT 'no', nextaftercmd ENUM('yes','no') DEFAULT 'yes', forcename ENUM('yes','no') DEFAULT 'no', forcegreetings ENUM('yes','no') DEFAULT 'no', hidefromdir ENUM('yes','no') DEFAULT 'yes', stamp DATETIME DEFAULT NULL, PRIMARY KEY (uniqueid), INDEX idx_context_mailbox (context, mailbox)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- ---- Queue Tables (for call queues) ----
CREATE TABLE IF NOT EXISTS queue_members ( uniqueid INT UNSIGNED NOT NULL AUTO_INCREMENT, membername VARCHAR(40) DEFAULT NULL, queue_name VARCHAR(128) DEFAULT NULL, interface VARCHAR(128) DEFAULT NULL, penalty INT DEFAULT NULL, paused INT DEFAULT NULL, state_interface VARCHAR(128) DEFAULT NULL, PRIMARY KEY (uniqueid), UNIQUE INDEX idx_queue_interface (queue_name, interface)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS queue_rules ( rule_name VARCHAR(80) NOT NULL DEFAULT '', time VARCHAR(32) NOT NULL DEFAULT '0', min_penalty VARCHAR(32) NOT NULL DEFAULT '0', max_penalty VARCHAR(32) NOT NULL DEFAULT '0', INDEX idx_rule_name (rule_name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- ---- Seed sample data ----
-- Insert sample endpoints into realtime tables
INSERT IGNORE INTO ps_endpoints (id, transport, aors, auth, context, disallow, allow, direct_media, dtmf_mode, force_rport, rtp_symmetric, rewrite_contact, callerid, mailboxes, call_group, pickup_group)
VALUES ('1001', 'transport-udp', '1001', '1001', 'from-internal', 'all', 'opus,g722,ulaw,alaw', 'no', 'rfc4733', 'yes', 'yes', 'yes', '"Reception" <1001>', '1001@default', '1', '1'), ('1002', 'transport-udp', '1002', '1002', 'from-internal', 'all', 'opus,g722,ulaw,alaw', 'no', 'rfc4733', 'yes', 'yes', 'yes', '"Sales" <1002>', '1002@default', '1', '1'); INSERT IGNORE INTO ps_auths (id, auth_type, username, password, realm)
VALUES ('1001', 'userpass', '1001', 'changeme', 'YOUR_DOMAIN'), ('1002', 'userpass', '1002', 'changeme', 'YOUR_DOMAIN'); INSERT IGNORE INTO ps_aors (id, max_contacts, remove_existing, qualify_frequency, qualify_timeout, default_expiration)
VALUES ('1001', 3, 'yes', 60, 5.0, 300), ('1002', 3, 'yes', 60, 5.0, 300); -- Insert sample voicemail users
INSERT IGNORE INTO voicemail_users (context, mailbox, password, fullname, email)
VALUES ('default', '1001', '1234', 'Reception', '[email protected]'), ('default', '1002', '1234', 'Sales', '[email protected]');
-- =============================================================================
-- Asterisk Database Schema
-- Executed automatically on first MariaDB container start
-- ============================================================================= USE asterisk; -- ---- CDR Table ----
CREATE TABLE IF NOT EXISTS cdr ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, calldate DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00', clid VARCHAR(80) NOT NULL DEFAULT '', src VARCHAR(80) NOT NULL DEFAULT '', dst VARCHAR(80) NOT NULL DEFAULT '', dcontext VARCHAR(80) NOT NULL DEFAULT '', channel VARCHAR(80) NOT NULL DEFAULT '', dstchannel VARCHAR(80) NOT NULL DEFAULT '', lastapp VARCHAR(80) NOT NULL DEFAULT '', lastdata VARCHAR(80) NOT NULL DEFAULT '', duration INT NOT NULL DEFAULT 0, billsec INT NOT NULL DEFAULT 0, disposition VARCHAR(45) NOT NULL DEFAULT '', amaflags INT NOT NULL DEFAULT 0, accountcode VARCHAR(20) NOT NULL DEFAULT '', uniqueid VARCHAR(150) NOT NULL DEFAULT '', userfield VARCHAR(255) NOT NULL DEFAULT '', peeraccount VARCHAR(20) NOT NULL DEFAULT '', linkedid VARCHAR(150) NOT NULL DEFAULT '', sequence INT NOT NULL DEFAULT 0, PRIMARY KEY (id), INDEX idx_calldate (calldate), INDEX idx_dst (dst), INDEX idx_src (src), INDEX idx_uniqueid (uniqueid), INDEX idx_disposition (disposition), INDEX idx_accountcode (accountcode), INDEX idx_clid (clid(20))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- ---- PJSIP Realtime Tables ----
-- These allow you to manage SIP endpoints via database instead of config files -- Endpoints
CREATE TABLE IF NOT EXISTS ps_endpoints ( id VARCHAR(40) NOT NULL, transport VARCHAR(40) DEFAULT NULL, aors VARCHAR(200) DEFAULT NULL, auth VARCHAR(40) DEFAULT NULL, context VARCHAR(40) DEFAULT 'from-internal', disallow VARCHAR(200) DEFAULT 'all', allow VARCHAR(200) DEFAULT 'opus,g722,ulaw,alaw', direct_media ENUM('yes','no') DEFAULT 'no', connected_line_method VARCHAR(40) DEFAULT NULL, direct_media_method VARCHAR(40) DEFAULT NULL, direct_media_glare_mitigation VARCHAR(40) DEFAULT NULL, disable_direct_media_on_nat ENUM('yes','no') DEFAULT NULL, dtmf_mode VARCHAR(40) DEFAULT 'rfc4733', external_media_address VARCHAR(40) DEFAULT NULL, force_rport ENUM('yes','no') DEFAULT 'yes', ice_support ENUM('yes','no') DEFAULT 'no', identify_by VARCHAR(80) DEFAULT NULL, mailboxes VARCHAR(40) DEFAULT NULL, media_address VARCHAR(40) DEFAULT NULL, media_encryption VARCHAR(40) DEFAULT 'no', media_use_received_transport ENUM('yes','no') DEFAULT NULL, 100rel VARCHAR(40) DEFAULT NULL, outbound_auth VARCHAR(40) DEFAULT NULL, outbound_proxy VARCHAR(256) DEFAULT NULL, rewrite_contact ENUM('yes','no') DEFAULT 'yes', rtp_ipv6 ENUM('yes','no') DEFAULT NULL, rtp_symmetric ENUM('yes','no') DEFAULT 'yes', send_diversion ENUM('yes','no') DEFAULT NULL, send_pai ENUM('yes','no') DEFAULT 'yes', send_rpid ENUM('yes','no') DEFAULT 'yes', timers_min_se INT DEFAULT NULL, timers VARCHAR(40) DEFAULT NULL, timers_sess_expires INT DEFAULT NULL, callerid VARCHAR(40) DEFAULT NULL, callerid_privacy VARCHAR(40) DEFAULT NULL, callerid_tag VARCHAR(40) DEFAULT NULL, trust_id_inbound ENUM('yes','no') DEFAULT 'yes', trust_id_outbound ENUM('yes','no') DEFAULT NULL, use_ptime ENUM('yes','no') DEFAULT NULL, use_avpf ENUM('yes','no') DEFAULT NULL, force_avp ENUM('yes','no') DEFAULT NULL, media_encryption_optimistic ENUM('yes','no') DEFAULT NULL, inband_progress ENUM('yes','no') DEFAULT NULL, call_group VARCHAR(40) DEFAULT NULL, pickup_group VARCHAR(40) DEFAULT NULL, named_call_group VARCHAR(40) DEFAULT NULL, named_pickup_group VARCHAR(40) DEFAULT NULL, device_state_busy_at INT DEFAULT NULL, t38_udptl ENUM('yes','no') DEFAULT NULL, t38_udptl_ec VARCHAR(40) DEFAULT NULL, t38_udptl_maxdatagram INT DEFAULT NULL, fax_detect ENUM('yes','no') DEFAULT NULL, t38_udptl_nat ENUM('yes','no') DEFAULT NULL, t38_udptl_ipv6 ENUM('yes','no') DEFAULT NULL, tone_zone VARCHAR(40) DEFAULT NULL, language VARCHAR(40) DEFAULT NULL, one_touch_recording ENUM('yes','no') DEFAULT NULL, record_on_feature VARCHAR(40) DEFAULT NULL, record_off_feature VARCHAR(40) DEFAULT NULL, rtp_engine VARCHAR(40) DEFAULT NULL, allow_transfer ENUM('yes','no') DEFAULT NULL, allow_subscribe ENUM('yes','no') DEFAULT NULL, sdp_owner VARCHAR(40) DEFAULT NULL, sdp_session VARCHAR(40) DEFAULT NULL, tos_audio VARCHAR(10) DEFAULT NULL, tos_video VARCHAR(10) DEFAULT NULL, sub_min_expiry INT DEFAULT NULL, from_domain VARCHAR(40) DEFAULT NULL, from_user VARCHAR(40) DEFAULT NULL, mwi_from_user VARCHAR(40) DEFAULT NULL, dtls_verify VARCHAR(40) DEFAULT NULL, dtls_rekey VARCHAR(40) DEFAULT NULL, dtls_cert_file VARCHAR(200) DEFAULT NULL, dtls_private_key VARCHAR(200) DEFAULT NULL, dtls_cipher VARCHAR(200) DEFAULT NULL, dtls_ca_file VARCHAR(200) DEFAULT NULL, dtls_ca_path VARCHAR(200) DEFAULT NULL, dtls_setup VARCHAR(40) DEFAULT NULL, srtp_tag_32 ENUM('yes','no') DEFAULT NULL, webrtc ENUM('yes','no') DEFAULT 'no', PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Authentication
CREATE TABLE IF NOT EXISTS ps_auths ( id VARCHAR(40) NOT NULL, auth_type VARCHAR(40) DEFAULT 'userpass', nonce_lifetime INT DEFAULT NULL, md5_cred VARCHAR(40) DEFAULT NULL, password VARCHAR(80) DEFAULT NULL, realm VARCHAR(40) DEFAULT NULL, username VARCHAR(40) DEFAULT NULL, PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- AORs (Address of Record)
CREATE TABLE IF NOT EXISTS ps_aors ( id VARCHAR(40) NOT NULL, contact VARCHAR(255) DEFAULT NULL, default_expiration INT DEFAULT 300, mailboxes VARCHAR(80) DEFAULT NULL, max_contacts INT DEFAULT 3, minimum_expiration INT DEFAULT 60, remove_existing ENUM('yes','no') DEFAULT 'yes', qualify_frequency INT DEFAULT 60, qualify_timeout FLOAT DEFAULT 3.0, authenticate_qualify ENUM('yes','no') DEFAULT NULL, outbound_proxy VARCHAR(256) DEFAULT NULL, support_path ENUM('yes','no') DEFAULT NULL, PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Contacts (registered devices)
CREATE TABLE IF NOT EXISTS ps_contacts ( id VARCHAR(255) NOT NULL, uri VARCHAR(255) DEFAULT NULL, expiration_time BIGINT DEFAULT NULL, qualify_frequency INT DEFAULT NULL, outbound_proxy VARCHAR(256) DEFAULT NULL, path TEXT DEFAULT NULL, user_agent VARCHAR(255) DEFAULT NULL, qualify_timeout FLOAT DEFAULT NULL, reg_server VARCHAR(20) DEFAULT NULL, authenticate_qualify ENUM('yes','no') DEFAULT NULL, via_addr VARCHAR(40) DEFAULT NULL, via_port INT DEFAULT NULL, call_id VARCHAR(255) DEFAULT NULL, endpoint VARCHAR(40) DEFAULT NULL, prune_on_boot ENUM('yes','no') DEFAULT 'yes', PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Endpoint Identification by IP
CREATE TABLE IF NOT EXISTS ps_endpoint_id_ips ( id VARCHAR(40) NOT NULL, endpoint VARCHAR(40) DEFAULT NULL, match VARCHAR(80) DEFAULT NULL, srv_lookups ENUM('yes','no') DEFAULT NULL, match_header VARCHAR(255) DEFAULT NULL, PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Domain Aliases
CREATE TABLE IF NOT EXISTS ps_domain_aliases ( id VARCHAR(40) NOT NULL, domain VARCHAR(80) DEFAULT NULL, PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Registrations (outbound registration to SIP trunks)
CREATE TABLE IF NOT EXISTS ps_registrations ( id VARCHAR(40) NOT NULL, auth_rejection_permanent ENUM('yes','no') DEFAULT NULL, client_uri VARCHAR(255) DEFAULT NULL, contact_user VARCHAR(40) DEFAULT NULL, expiration INT DEFAULT NULL, max_retries INT DEFAULT NULL, outbound_auth VARCHAR(40) DEFAULT NULL, outbound_proxy VARCHAR(256) DEFAULT NULL, retry_interval INT DEFAULT NULL, forbidden_retry_interval INT DEFAULT NULL, server_uri VARCHAR(255) DEFAULT NULL, transport VARCHAR(40) DEFAULT NULL, support_path ENUM('yes','no') DEFAULT NULL, line ENUM('yes','no') DEFAULT NULL, endpoint VARCHAR(40) DEFAULT NULL, PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- ---- Voicemail Users Table ----
CREATE TABLE IF NOT EXISTS voicemail_users ( uniqueid INT UNSIGNED NOT NULL AUTO_INCREMENT, context VARCHAR(50) NOT NULL DEFAULT 'default', mailbox VARCHAR(20) NOT NULL, password VARCHAR(20) NOT NULL DEFAULT '1234', fullname VARCHAR(150) DEFAULT NULL, email VARCHAR(250) DEFAULT NULL, pager VARCHAR(250) DEFAULT NULL, tz VARCHAR(80) DEFAULT 'central', attach ENUM('yes','no') DEFAULT 'yes', saycid ENUM('yes','no') DEFAULT 'yes', dialout VARCHAR(10) DEFAULT NULL, callback VARCHAR(10) DEFAULT NULL, review ENUM('yes','no') DEFAULT 'no', operator ENUM('yes','no') DEFAULT 'no', envelope ENUM('yes','no') DEFAULT 'no', sayduration ENUM('yes','no') DEFAULT 'yes', saydurationm INT DEFAULT 1, sendvoicemail ENUM('yes','no') DEFAULT 'no', delete_vm ENUM('yes','no') DEFAULT 'no', nextaftercmd ENUM('yes','no') DEFAULT 'yes', forcename ENUM('yes','no') DEFAULT 'no', forcegreetings ENUM('yes','no') DEFAULT 'no', hidefromdir ENUM('yes','no') DEFAULT 'yes', stamp DATETIME DEFAULT NULL, PRIMARY KEY (uniqueid), INDEX idx_context_mailbox (context, mailbox)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- ---- Queue Tables (for call queues) ----
CREATE TABLE IF NOT EXISTS queue_members ( uniqueid INT UNSIGNED NOT NULL AUTO_INCREMENT, membername VARCHAR(40) DEFAULT NULL, queue_name VARCHAR(128) DEFAULT NULL, interface VARCHAR(128) DEFAULT NULL, penalty INT DEFAULT NULL, paused INT DEFAULT NULL, state_interface VARCHAR(128) DEFAULT NULL, PRIMARY KEY (uniqueid), UNIQUE INDEX idx_queue_interface (queue_name, interface)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS queue_rules ( rule_name VARCHAR(80) NOT NULL DEFAULT '', time VARCHAR(32) NOT NULL DEFAULT '0', min_penalty VARCHAR(32) NOT NULL DEFAULT '0', max_penalty VARCHAR(32) NOT NULL DEFAULT '0', INDEX idx_rule_name (rule_name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- ---- Seed sample data ----
-- Insert sample endpoints into realtime tables
INSERT IGNORE INTO ps_endpoints (id, transport, aors, auth, context, disallow, allow, direct_media, dtmf_mode, force_rport, rtp_symmetric, rewrite_contact, callerid, mailboxes, call_group, pickup_group)
VALUES ('1001', 'transport-udp', '1001', '1001', 'from-internal', 'all', 'opus,g722,ulaw,alaw', 'no', 'rfc4733', 'yes', 'yes', 'yes', '"Reception" <1001>', '1001@default', '1', '1'), ('1002', 'transport-udp', '1002', '1002', 'from-internal', 'all', 'opus,g722,ulaw,alaw', 'no', 'rfc4733', 'yes', 'yes', 'yes', '"Sales" <1002>', '1002@default', '1', '1'); INSERT IGNORE INTO ps_auths (id, auth_type, username, password, realm)
VALUES ('1001', 'userpass', '1001', 'changeme', 'YOUR_DOMAIN'), ('1002', 'userpass', '1002', 'changeme', 'YOUR_DOMAIN'); INSERT IGNORE INTO ps_aors (id, max_contacts, remove_existing, qualify_frequency, qualify_timeout, default_expiration)
VALUES ('1001', 3, 'yes', 60, 5.0, 300), ('1002', 3, 'yes', 60, 5.0, 300); -- Insert sample voicemail users
INSERT IGNORE INTO voicemail_users (context, mailbox, password, fullname, email)
VALUES ('default', '1001', '1234', 'Reception', '[email protected]'), ('default', '1002', '1234', 'Sales', '[email protected]');
-- =============================================================================
-- Asterisk Database Schema
-- Executed automatically on first MariaDB container start
-- ============================================================================= USE asterisk; -- ---- CDR Table ----
CREATE TABLE IF NOT EXISTS cdr ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, calldate DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00', clid VARCHAR(80) NOT NULL DEFAULT '', src VARCHAR(80) NOT NULL DEFAULT '', dst VARCHAR(80) NOT NULL DEFAULT '', dcontext VARCHAR(80) NOT NULL DEFAULT '', channel VARCHAR(80) NOT NULL DEFAULT '', dstchannel VARCHAR(80) NOT NULL DEFAULT '', lastapp VARCHAR(80) NOT NULL DEFAULT '', lastdata VARCHAR(80) NOT NULL DEFAULT '', duration INT NOT NULL DEFAULT 0, billsec INT NOT NULL DEFAULT 0, disposition VARCHAR(45) NOT NULL DEFAULT '', amaflags INT NOT NULL DEFAULT 0, accountcode VARCHAR(20) NOT NULL DEFAULT '', uniqueid VARCHAR(150) NOT NULL DEFAULT '', userfield VARCHAR(255) NOT NULL DEFAULT '', peeraccount VARCHAR(20) NOT NULL DEFAULT '', linkedid VARCHAR(150) NOT NULL DEFAULT '', sequence INT NOT NULL DEFAULT 0, PRIMARY KEY (id), INDEX idx_calldate (calldate), INDEX idx_dst (dst), INDEX idx_src (src), INDEX idx_uniqueid (uniqueid), INDEX idx_disposition (disposition), INDEX idx_accountcode (accountcode), INDEX idx_clid (clid(20))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- ---- PJSIP Realtime Tables ----
-- These allow you to manage SIP endpoints via database instead of config files -- Endpoints
CREATE TABLE IF NOT EXISTS ps_endpoints ( id VARCHAR(40) NOT NULL, transport VARCHAR(40) DEFAULT NULL, aors VARCHAR(200) DEFAULT NULL, auth VARCHAR(40) DEFAULT NULL, context VARCHAR(40) DEFAULT 'from-internal', disallow VARCHAR(200) DEFAULT 'all', allow VARCHAR(200) DEFAULT 'opus,g722,ulaw,alaw', direct_media ENUM('yes','no') DEFAULT 'no', connected_line_method VARCHAR(40) DEFAULT NULL, direct_media_method VARCHAR(40) DEFAULT NULL, direct_media_glare_mitigation VARCHAR(40) DEFAULT NULL, disable_direct_media_on_nat ENUM('yes','no') DEFAULT NULL, dtmf_mode VARCHAR(40) DEFAULT 'rfc4733', external_media_address VARCHAR(40) DEFAULT NULL, force_rport ENUM('yes','no') DEFAULT 'yes', ice_support ENUM('yes','no') DEFAULT 'no', identify_by VARCHAR(80) DEFAULT NULL, mailboxes VARCHAR(40) DEFAULT NULL, media_address VARCHAR(40) DEFAULT NULL, media_encryption VARCHAR(40) DEFAULT 'no', media_use_received_transport ENUM('yes','no') DEFAULT NULL, 100rel VARCHAR(40) DEFAULT NULL, outbound_auth VARCHAR(40) DEFAULT NULL, outbound_proxy VARCHAR(256) DEFAULT NULL, rewrite_contact ENUM('yes','no') DEFAULT 'yes', rtp_ipv6 ENUM('yes','no') DEFAULT NULL, rtp_symmetric ENUM('yes','no') DEFAULT 'yes', send_diversion ENUM('yes','no') DEFAULT NULL, send_pai ENUM('yes','no') DEFAULT 'yes', send_rpid ENUM('yes','no') DEFAULT 'yes', timers_min_se INT DEFAULT NULL, timers VARCHAR(40) DEFAULT NULL, timers_sess_expires INT DEFAULT NULL, callerid VARCHAR(40) DEFAULT NULL, callerid_privacy VARCHAR(40) DEFAULT NULL, callerid_tag VARCHAR(40) DEFAULT NULL, trust_id_inbound ENUM('yes','no') DEFAULT 'yes', trust_id_outbound ENUM('yes','no') DEFAULT NULL, use_ptime ENUM('yes','no') DEFAULT NULL, use_avpf ENUM('yes','no') DEFAULT NULL, force_avp ENUM('yes','no') DEFAULT NULL, media_encryption_optimistic ENUM('yes','no') DEFAULT NULL, inband_progress ENUM('yes','no') DEFAULT NULL, call_group VARCHAR(40) DEFAULT NULL, pickup_group VARCHAR(40) DEFAULT NULL, named_call_group VARCHAR(40) DEFAULT NULL, named_pickup_group VARCHAR(40) DEFAULT NULL, device_state_busy_at INT DEFAULT NULL, t38_udptl ENUM('yes','no') DEFAULT NULL, t38_udptl_ec VARCHAR(40) DEFAULT NULL, t38_udptl_maxdatagram INT DEFAULT NULL, fax_detect ENUM('yes','no') DEFAULT NULL, t38_udptl_nat ENUM('yes','no') DEFAULT NULL, t38_udptl_ipv6 ENUM('yes','no') DEFAULT NULL, tone_zone VARCHAR(40) DEFAULT NULL, language VARCHAR(40) DEFAULT NULL, one_touch_recording ENUM('yes','no') DEFAULT NULL, record_on_feature VARCHAR(40) DEFAULT NULL, record_off_feature VARCHAR(40) DEFAULT NULL, rtp_engine VARCHAR(40) DEFAULT NULL, allow_transfer ENUM('yes','no') DEFAULT NULL, allow_subscribe ENUM('yes','no') DEFAULT NULL, sdp_owner VARCHAR(40) DEFAULT NULL, sdp_session VARCHAR(40) DEFAULT NULL, tos_audio VARCHAR(10) DEFAULT NULL, tos_video VARCHAR(10) DEFAULT NULL, sub_min_expiry INT DEFAULT NULL, from_domain VARCHAR(40) DEFAULT NULL, from_user VARCHAR(40) DEFAULT NULL, mwi_from_user VARCHAR(40) DEFAULT NULL, dtls_verify VARCHAR(40) DEFAULT NULL, dtls_rekey VARCHAR(40) DEFAULT NULL, dtls_cert_file VARCHAR(200) DEFAULT NULL, dtls_private_key VARCHAR(200) DEFAULT NULL, dtls_cipher VARCHAR(200) DEFAULT NULL, dtls_ca_file VARCHAR(200) DEFAULT NULL, dtls_ca_path VARCHAR(200) DEFAULT NULL, dtls_setup VARCHAR(40) DEFAULT NULL, srtp_tag_32 ENUM('yes','no') DEFAULT NULL, webrtc ENUM('yes','no') DEFAULT 'no', PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Authentication
CREATE TABLE IF NOT EXISTS ps_auths ( id VARCHAR(40) NOT NULL, auth_type VARCHAR(40) DEFAULT 'userpass', nonce_lifetime INT DEFAULT NULL, md5_cred VARCHAR(40) DEFAULT NULL, password VARCHAR(80) DEFAULT NULL, realm VARCHAR(40) DEFAULT NULL, username VARCHAR(40) DEFAULT NULL, PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- AORs (Address of Record)
CREATE TABLE IF NOT EXISTS ps_aors ( id VARCHAR(40) NOT NULL, contact VARCHAR(255) DEFAULT NULL, default_expiration INT DEFAULT 300, mailboxes VARCHAR(80) DEFAULT NULL, max_contacts INT DEFAULT 3, minimum_expiration INT DEFAULT 60, remove_existing ENUM('yes','no') DEFAULT 'yes', qualify_frequency INT DEFAULT 60, qualify_timeout FLOAT DEFAULT 3.0, authenticate_qualify ENUM('yes','no') DEFAULT NULL, outbound_proxy VARCHAR(256) DEFAULT NULL, support_path ENUM('yes','no') DEFAULT NULL, PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Contacts (registered devices)
CREATE TABLE IF NOT EXISTS ps_contacts ( id VARCHAR(255) NOT NULL, uri VARCHAR(255) DEFAULT NULL, expiration_time BIGINT DEFAULT NULL, qualify_frequency INT DEFAULT NULL, outbound_proxy VARCHAR(256) DEFAULT NULL, path TEXT DEFAULT NULL, user_agent VARCHAR(255) DEFAULT NULL, qualify_timeout FLOAT DEFAULT NULL, reg_server VARCHAR(20) DEFAULT NULL, authenticate_qualify ENUM('yes','no') DEFAULT NULL, via_addr VARCHAR(40) DEFAULT NULL, via_port INT DEFAULT NULL, call_id VARCHAR(255) DEFAULT NULL, endpoint VARCHAR(40) DEFAULT NULL, prune_on_boot ENUM('yes','no') DEFAULT 'yes', PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Endpoint Identification by IP
CREATE TABLE IF NOT EXISTS ps_endpoint_id_ips ( id VARCHAR(40) NOT NULL, endpoint VARCHAR(40) DEFAULT NULL, match VARCHAR(80) DEFAULT NULL, srv_lookups ENUM('yes','no') DEFAULT NULL, match_header VARCHAR(255) DEFAULT NULL, PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Domain Aliases
CREATE TABLE IF NOT EXISTS ps_domain_aliases ( id VARCHAR(40) NOT NULL, domain VARCHAR(80) DEFAULT NULL, PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Registrations (outbound registration to SIP trunks)
CREATE TABLE IF NOT EXISTS ps_registrations ( id VARCHAR(40) NOT NULL, auth_rejection_permanent ENUM('yes','no') DEFAULT NULL, client_uri VARCHAR(255) DEFAULT NULL, contact_user VARCHAR(40) DEFAULT NULL, expiration INT DEFAULT NULL, max_retries INT DEFAULT NULL, outbound_auth VARCHAR(40) DEFAULT NULL, outbound_proxy VARCHAR(256) DEFAULT NULL, retry_interval INT DEFAULT NULL, forbidden_retry_interval INT DEFAULT NULL, server_uri VARCHAR(255) DEFAULT NULL, transport VARCHAR(40) DEFAULT NULL, support_path ENUM('yes','no') DEFAULT NULL, line ENUM('yes','no') DEFAULT NULL, endpoint VARCHAR(40) DEFAULT NULL, PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- ---- Voicemail Users Table ----
CREATE TABLE IF NOT EXISTS voicemail_users ( uniqueid INT UNSIGNED NOT NULL AUTO_INCREMENT, context VARCHAR(50) NOT NULL DEFAULT 'default', mailbox VARCHAR(20) NOT NULL, password VARCHAR(20) NOT NULL DEFAULT '1234', fullname VARCHAR(150) DEFAULT NULL, email VARCHAR(250) DEFAULT NULL, pager VARCHAR(250) DEFAULT NULL, tz VARCHAR(80) DEFAULT 'central', attach ENUM('yes','no') DEFAULT 'yes', saycid ENUM('yes','no') DEFAULT 'yes', dialout VARCHAR(10) DEFAULT NULL, callback VARCHAR(10) DEFAULT NULL, review ENUM('yes','no') DEFAULT 'no', operator ENUM('yes','no') DEFAULT 'no', envelope ENUM('yes','no') DEFAULT 'no', sayduration ENUM('yes','no') DEFAULT 'yes', saydurationm INT DEFAULT 1, sendvoicemail ENUM('yes','no') DEFAULT 'no', delete_vm ENUM('yes','no') DEFAULT 'no', nextaftercmd ENUM('yes','no') DEFAULT 'yes', forcename ENUM('yes','no') DEFAULT 'no', forcegreetings ENUM('yes','no') DEFAULT 'no', hidefromdir ENUM('yes','no') DEFAULT 'yes', stamp DATETIME DEFAULT NULL, PRIMARY KEY (uniqueid), INDEX idx_context_mailbox (context, mailbox)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- ---- Queue Tables (for call queues) ----
CREATE TABLE IF NOT EXISTS queue_members ( uniqueid INT UNSIGNED NOT NULL AUTO_INCREMENT, membername VARCHAR(40) DEFAULT NULL, queue_name VARCHAR(128) DEFAULT NULL, interface VARCHAR(128) DEFAULT NULL, penalty INT DEFAULT NULL, paused INT DEFAULT NULL, state_interface VARCHAR(128) DEFAULT NULL, PRIMARY KEY (uniqueid), UNIQUE INDEX idx_queue_interface (queue_name, interface)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS queue_rules ( rule_name VARCHAR(80) NOT NULL DEFAULT '', time VARCHAR(32) NOT NULL DEFAULT '0', min_penalty VARCHAR(32) NOT NULL DEFAULT '0', max_penalty VARCHAR(32) NOT NULL DEFAULT '0', INDEX idx_rule_name (rule_name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- ---- Seed sample data ----
-- Insert sample endpoints into realtime tables
INSERT IGNORE INTO ps_endpoints (id, transport, aors, auth, context, disallow, allow, direct_media, dtmf_mode, force_rport, rtp_symmetric, rewrite_contact, callerid, mailboxes, call_group, pickup_group)
VALUES ('1001', 'transport-udp', '1001', '1001', 'from-internal', 'all', 'opus,g722,ulaw,alaw', 'no', 'rfc4733', 'yes', 'yes', 'yes', '"Reception" <1001>', '1001@default', '1', '1'), ('1002', 'transport-udp', '1002', '1002', 'from-internal', 'all', 'opus,g722,ulaw,alaw', 'no', 'rfc4733', 'yes', 'yes', 'yes', '"Sales" <1002>', '1002@default', '1', '1'); INSERT IGNORE INTO ps_auths (id, auth_type, username, password, realm)
VALUES ('1001', 'userpass', '1001', 'changeme', 'YOUR_DOMAIN'), ('1002', 'userpass', '1002', 'changeme', 'YOUR_DOMAIN'); INSERT IGNORE INTO ps_aors (id, max_contacts, remove_existing, qualify_frequency, qualify_timeout, default_expiration)
VALUES ('1001', 3, 'yes', 60, 5.0, 300), ('1002', 3, 'yes', 60, 5.0, 300); -- Insert sample voicemail users
INSERT IGNORE INTO voicemail_users (context, mailbox, password, fullname, email)
VALUES ('default', '1001', '1234', 'Reception', '[email protected]'), ('default', '1002', '1234', 'Sales', '[email protected]');
; =============================================================================
; Sorcery Configuration - Maps PJSIP objects to database tables
; ============================================================================= [res_pjsip]
endpoint = realtime,ps_endpoints
auth = realtime,ps_auths
aor = realtime,ps_aors
domain_alias = realtime,ps_domain_aliases
contact = realtime,ps_contacts [res_pjsip_endpoint_identifier_ip]
identify = realtime,ps_endpoint_id_ips [res_pjsip_outbound_registration]
registration = realtime,ps_registrations
; =============================================================================
; Sorcery Configuration - Maps PJSIP objects to database tables
; ============================================================================= [res_pjsip]
endpoint = realtime,ps_endpoints
auth = realtime,ps_auths
aor = realtime,ps_aors
domain_alias = realtime,ps_domain_aliases
contact = realtime,ps_contacts [res_pjsip_endpoint_identifier_ip]
identify = realtime,ps_endpoint_id_ips [res_pjsip_outbound_registration]
registration = realtime,ps_registrations
; =============================================================================
; Sorcery Configuration - Maps PJSIP objects to database tables
; ============================================================================= [res_pjsip]
endpoint = realtime,ps_endpoints
auth = realtime,ps_auths
aor = realtime,ps_aors
domain_alias = realtime,ps_domain_aliases
contact = realtime,ps_contacts [res_pjsip_endpoint_identifier_ip]
identify = realtime,ps_endpoint_id_ips [res_pjsip_outbound_registration]
registration = realtime,ps_registrations
; =============================================================================
; External Configuration - Maps Asterisk subsystems to ODBC
; ============================================================================= [settings]
ps_endpoints => odbc,asterisk-connector,ps_endpoints
ps_auths => odbc,asterisk-connector,ps_auths
ps_aors => odbc,asterisk-connector,ps_aors
ps_contacts => odbc,asterisk-connector,ps_contacts
ps_domain_aliases => odbc,asterisk-connector,ps_domain_aliases
ps_endpoint_id_ips => odbc,asterisk-connector,ps_endpoint_id_ips
ps_registrations => odbc,asterisk-connector,ps_registrations
voicemail => odbc,asterisk-connector,voicemail_users
queue_members => odbc,asterisk-connector,queue_members
queue_rules => odbc,asterisk-connector,queue_rules
; =============================================================================
; External Configuration - Maps Asterisk subsystems to ODBC
; ============================================================================= [settings]
ps_endpoints => odbc,asterisk-connector,ps_endpoints
ps_auths => odbc,asterisk-connector,ps_auths
ps_aors => odbc,asterisk-connector,ps_aors
ps_contacts => odbc,asterisk-connector,ps_contacts
ps_domain_aliases => odbc,asterisk-connector,ps_domain_aliases
ps_endpoint_id_ips => odbc,asterisk-connector,ps_endpoint_id_ips
ps_registrations => odbc,asterisk-connector,ps_registrations
voicemail => odbc,asterisk-connector,voicemail_users
queue_members => odbc,asterisk-connector,queue_members
queue_rules => odbc,asterisk-connector,queue_rules
; =============================================================================
; External Configuration - Maps Asterisk subsystems to ODBC
; ============================================================================= [settings]
ps_endpoints => odbc,asterisk-connector,ps_endpoints
ps_auths => odbc,asterisk-connector,ps_auths
ps_aors => odbc,asterisk-connector,ps_aors
ps_contacts => odbc,asterisk-connector,ps_contacts
ps_domain_aliases => odbc,asterisk-connector,ps_domain_aliases
ps_endpoint_id_ips => odbc,asterisk-connector,ps_endpoint_id_ips
ps_registrations => odbc,asterisk-connector,ps_registrations
voicemail => odbc,asterisk-connector,voicemail_users
queue_members => odbc,asterisk-connector,queue_members
queue_rules => odbc,asterisk-connector,queue_rules
-- Add a new extension
INSERT INTO ps_endpoints (id, transport, aors, auth, context, disallow, allow, direct_media, dtmf_mode, force_rport, rtp_symmetric, rewrite_contact, callerid, mailboxes)
VALUES ('1003', 'transport-udp', '1003', '1003', 'from-internal', 'all', 'opus,g722,ulaw,alaw', 'no', 'rfc4733', 'yes', 'yes', 'yes', '"Support" <1003>', '1003@default'); INSERT INTO ps_auths (id, auth_type, username, password, realm)
VALUES ('1003', 'userpass', '1003', 'SecurePass123!', 'YOUR_DOMAIN'); INSERT INTO ps_aors (id, max_contacts, remove_existing, qualify_frequency)
VALUES ('1003', 3, 'yes', 60); -- The endpoint is immediately available - no reload needed!
-- Verify from Asterisk CLI:
-- asterisk -rx "pjsip show endpoint 1003"
-- Add a new extension
INSERT INTO ps_endpoints (id, transport, aors, auth, context, disallow, allow, direct_media, dtmf_mode, force_rport, rtp_symmetric, rewrite_contact, callerid, mailboxes)
VALUES ('1003', 'transport-udp', '1003', '1003', 'from-internal', 'all', 'opus,g722,ulaw,alaw', 'no', 'rfc4733', 'yes', 'yes', 'yes', '"Support" <1003>', '1003@default'); INSERT INTO ps_auths (id, auth_type, username, password, realm)
VALUES ('1003', 'userpass', '1003', 'SecurePass123!', 'YOUR_DOMAIN'); INSERT INTO ps_aors (id, max_contacts, remove_existing, qualify_frequency)
VALUES ('1003', 3, 'yes', 60); -- The endpoint is immediately available - no reload needed!
-- Verify from Asterisk CLI:
-- asterisk -rx "pjsip show endpoint 1003"
-- Add a new extension
INSERT INTO ps_endpoints (id, transport, aors, auth, context, disallow, allow, direct_media, dtmf_mode, force_rport, rtp_symmetric, rewrite_contact, callerid, mailboxes)
VALUES ('1003', 'transport-udp', '1003', '1003', 'from-internal', 'all', 'opus,g722,ulaw,alaw', 'no', 'rfc4733', 'yes', 'yes', 'yes', '"Support" <1003>', '1003@default'); INSERT INTO ps_auths (id, auth_type, username, password, realm)
VALUES ('1003', 'userpass', '1003', 'SecurePass123!', 'YOUR_DOMAIN'); INSERT INTO ps_aors (id, max_contacts, remove_existing, qualify_frequency)
VALUES ('1003', 3, 'yes', 60); -- The endpoint is immediately available - no reload needed!
-- Verify from Asterisk CLI:
-- asterisk -rx "pjsip show endpoint 1003"
# From the Docker host, you can manage endpoints via:
docker exec asterisk-mariadb mysql -u asterisk -p'Ast3r1sk_DB_2026!' asterisk \ -e "SELECT id, context, callerid FROM ps_endpoints;"
# From the Docker host, you can manage endpoints via:
docker exec asterisk-mariadb mysql -u asterisk -p'Ast3r1sk_DB_2026!' asterisk \ -e "SELECT id, context, callerid FROM ps_endpoints;"
# From the Docker host, you can manage endpoints via:
docker exec asterisk-mariadb mysql -u asterisk -p'Ast3r1sk_DB_2026!' asterisk \ -e "SELECT id, context, callerid FROM ps_endpoints;"
[asterisk-connector]
enabled = yes
dsn = asterisk-connector
username = ${DB_USER}
password = ${DB_PASSWORD}
pre-connect = yes ; Establish connections at startup
max_connections = 20 ; Pool size (increase for busy systems)
connect_timeout = 5 ; Seconds before connection attempt fails
negative_connection_cache = 600 ; Cache failed connection for 10 min
sanitysql = SELECT 1 ; Keep-alive query
[asterisk-connector]
enabled = yes
dsn = asterisk-connector
username = ${DB_USER}
password = ${DB_PASSWORD}
pre-connect = yes ; Establish connections at startup
max_connections = 20 ; Pool size (increase for busy systems)
connect_timeout = 5 ; Seconds before connection attempt fails
negative_connection_cache = 600 ; Cache failed connection for 10 min
sanitysql = SELECT 1 ; Keep-alive query
[asterisk-connector]
enabled = yes
dsn = asterisk-connector
username = ${DB_USER}
password = ${DB_PASSWORD}
pre-connect = yes ; Establish connections at startup
max_connections = 20 ; Pool size (increase for busy systems)
connect_timeout = 5 ; Seconds before connection attempt fails
negative_connection_cache = 600 ; Cache failed connection for 10 min
sanitysql = SELECT 1 ; Keep-alive query
docker compose exec asterisk asterisk -rx "odbc show all"
# Name: asterisk-connector
# DSN: asterisk-connector
# Last connection attempt: 2026-01-15 10:30:00
# Pooled: Yes
# Connected: Yes (20 connections)
docker compose exec asterisk asterisk -rx "odbc show all"
# Name: asterisk-connector
# DSN: asterisk-connector
# Last connection attempt: 2026-01-15 10:30:00
# Pooled: Yes
# Connected: Yes (20 connections)
docker compose exec asterisk asterisk -rx "odbc show all"
# Name: asterisk-connector
# DSN: asterisk-connector
# Last connection attempt: 2026-01-15 10:30:00
# Pooled: Yes
# Connected: Yes (20 connections)
┌─────────────────┐ REST API ┌──────────────────┐
│ Your App │◄──────────────────►│ Asterisk ARI │
│ (Python/Node) │ WebSocket │ Port 8088 │
│ │◄──────────────────►│ │
└─────────────────┘ └──────────────────┘ REST API: POST /ari/channels → Originate a call POST /ari/channels/{id}/answer → Answer a call POST /ari/channels/{id}/play → Play audio POST /ari/bridges → Create a bridge (conference) POST /ari/bridges/{id}/addChannel → Add channel to bridge DELETE /ari/channels/{id} → Hang up WebSocket (/ari/events): StasisStart → Call entered your application StasisEnd → Call left your application ChannelDtmfReceived → User pressed a key ChannelStateChange → Channel state changed PlaybackFinished → Audio playback completed
┌─────────────────┐ REST API ┌──────────────────┐
│ Your App │◄──────────────────►│ Asterisk ARI │
│ (Python/Node) │ WebSocket │ Port 8088 │
│ │◄──────────────────►│ │
└─────────────────┘ └──────────────────┘ REST API: POST /ari/channels → Originate a call POST /ari/channels/{id}/answer → Answer a call POST /ari/channels/{id}/play → Play audio POST /ari/bridges → Create a bridge (conference) POST /ari/bridges/{id}/addChannel → Add channel to bridge DELETE /ari/channels/{id} → Hang up WebSocket (/ari/events): StasisStart → Call entered your application StasisEnd → Call left your application ChannelDtmfReceived → User pressed a key ChannelStateChange → Channel state changed PlaybackFinished → Audio playback completed
┌─────────────────┐ REST API ┌──────────────────┐
│ Your App │◄──────────────────►│ Asterisk ARI │
│ (Python/Node) │ WebSocket │ Port 8088 │
│ │◄──────────────────►│ │
└─────────────────┘ └──────────────────┘ REST API: POST /ari/channels → Originate a call POST /ari/channels/{id}/answer → Answer a call POST /ari/channels/{id}/play → Play audio POST /ari/bridges → Create a bridge (conference) POST /ari/bridges/{id}/addChannel → Add channel to bridge DELETE /ari/channels/{id} → Hang up WebSocket (/ari/events): StasisStart → Call entered your application StasisEnd → Call left your application ChannelDtmfReceived → User pressed a key ChannelStateChange → Channel state changed PlaybackFinished → Audio playback completed
requests>=2.31.0
websocket-client>=1.7.0
redis>=5.0.0
requests>=2.31.0
websocket-client>=1.7.0
redis>=5.0.0
requests>=2.31.0
websocket-client>=1.7.0
redis>=5.0.0
#!/usr/bin/env python3
"""
Asterisk ARI Auto-Attendant Application
Handles inbound calls via REST API + WebSocket events. Requires: - Asterisk with ARI enabled (http.conf + ari.conf) - Dialplan: exten => _X.,1,Stasis(autoattendant,${EXTEN}) - pip install requests websocket-client redis
""" import json
import logging
import os
import sys
import threading
import time
from typing import Any import redis
import requests
import websocket # =============================================================================
# Configuration
# ============================================================================= ARI_URL = os.getenv("ARI_URL", "http://asterisk:8088")
ARI_USER = os.getenv("ARI_USERNAME", "ari_user")
ARI_PASS = os.getenv("ARI_PASSWORD", "Ar1_S3cur3_2026!")
ARI_APP = os.getenv("ARI_APP", "autoattendant") REDIS_HOST = os.getenv("REDIS_HOST", "redis")
REDIS_PORT = int(os.getenv("REDIS_PORT", "6379"))
REDIS_PASS = os.getenv("REDIS_PASSWORD", "R3d1s_S3cur3_2026!") # Logging
logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s", datefmt="%Y-%m-%d %H:%M:%S",
)
log = logging.getLogger("ari-autoattendant") # =============================================================================
# Redis Client (for state tracking)
# ============================================================================= redis_client = redis.Redis( host=REDIS_HOST, port=REDIS_PORT, password=REDIS_PASS, decode_responses=True,
) # =============================================================================
# ARI REST Client
# ============================================================================= class ARIClient: """Thin wrapper around the ARI REST API.""" def __init__(self, base_url: str, username: str, password: str): self.base_url = base_url.rstrip("/") self.auth = (username, password) self.session = requests.Session() self.session.auth = self.auth def get(self, path: str, **kwargs) -> Any: resp = self.session.get(f"{self.base_url}/ari{path}", **kwargs) resp.raise_for_status() return resp.json() if resp.content else None def post(self, path: str, **kwargs) -> Any: resp = self.session.post(f"{self.base_url}/ari{path}", **kwargs) resp.raise_for_status() return resp.json() if resp.content else None def delete(self, path: str, **kwargs) -> Any: resp = self.session.delete(f"{self.base_url}/ari{path}", **kwargs) resp.raise_for_status() return resp.json() if resp.content else None # ---- Channel Operations ---- def answer(self, channel_id: str): """Answer a channel.""" return self.post(f"/channels/{channel_id}/answer") def hangup(self, channel_id: str, reason: str = "normal"): """Hang up a channel.""" return self.delete(f"/channels/{channel_id}", params={"reason": reason}) def play(self, channel_id: str, media: str, playback_id: str = None): """Play media on a channel. Media format: 'sound:filename' or 'tone:dial'.""" params = {"media": media} if playback_id: params["playbackId"] = playback_id return self.post(f"/channels/{channel_id}/play", params=params) def dial(self, channel_id: str, endpoint: str, context: str = None, caller_id: str = None, timeout: int = 30): """Originate a call and bridge it.""" params = { "endpoint": endpoint, "app": ARI_APP, "timeout": timeout, } if caller_id: params["callerId"] = caller_id return self.post("/channels", params=params) # ---- Bridge Operations ---- def create_bridge(self, bridge_type: str = "mixing", name: str = None): """Create a bridge (conference).""" params = {"type": bridge_type} if name: params["name"] = name return self.post("/bridges", params=params) def add_to_bridge(self, bridge_id: str, channel_id: str): """Add a channel to a bridge.""" return self.post(f"/bridges/{bridge_id}/addChannel", params={"channel": channel_id}) def remove_from_bridge(self, bridge_id: str, channel_id: str): """Remove a channel from a bridge.""" return self.post(f"/bridges/{bridge_id}/removeChannel", params={"channel": channel_id}) def destroy_bridge(self, bridge_id: str): """Destroy a bridge.""" return self.delete(f"/bridges/{bridge_id}") # =============================================================================
# Call Handler
# ============================================================================= class CallHandler: """Handles a single call through the IVR flow.""" # IVR menu options: DTMF digit -> (description, PJSIP endpoint to dial) MENU = { "1": ("Sales", "PJSIP/1001"), "2": ("Support", "PJSIP/1004"), "3": ("Directory", None), # Sub-menu "0": ("Operator", "PJSIP/1001"), } def __init__(self, ari: ARIClient, channel_id: str, caller_info: dict): self.ari = ari self.channel_id = channel_id self.caller_info = caller_info self.state = "greeting" self.dtmf_buffer = "" self.attempts = 0 self.max_attempts = 3 self.bridge_id = None # Track state in Redis redis_client.hset(f"call:{channel_id}", mapping={ "state": self.state, "caller_num": caller_info.get("number", "unknown"), "caller_name": caller_info.get("name", "unknown"), "start_time": str(int(time.time())), }) def start(self): """Begin the IVR flow.""" log.info(f"Call {self.channel_id}: answering from {self.caller_info}") self.ari.answer(self.channel_id) time.sleep(0.5) # Brief pause after answer self._play_greeting() def _play_greeting(self): """Play the main greeting.""" self.state = "greeting" self._update_state() # Play greeting - uses built-in Asterisk sounds # In production, replace with custom recordings self.ari.play(self.channel_id, "sound:custom/welcome", playback_id=f"greeting-{self.channel_id}") def handle_dtmf(self, digit: str): """Process a DTMF digit.""" log.info(f"Call {self.channel_id}: DTMF '{digit}' in state '{self.state}'") if self.state in ("greeting", "waiting"): self._process_menu_selection(digit) elif self.state == "directory": self._process_directory_input(digit) def _process_menu_selection(self, digit: str): """Handle main menu DTMF selection.""" if digit in self.MENU: description, endpoint = self.MENU[digit] log.info(f"Call {self.channel_id}: selected '{description}'") if endpoint: self._transfer_to(endpoint, description) elif digit == "3": self.state = "directory" self._update_state() self.ari.play(self.channel_id, "sound:dir-pls-enter-person", playback_id=f"directory-{self.channel_id}") elif digit == "#": # Repeat menu self._play_greeting() else: self.ari.play(self.channel_id, "sound:option-is-invalid", playback_id=f"invalid-{self.channel_id}") self.attempts += 1 if self.attempts >= self.max_attempts: log.info(f"Call {self.channel_id}: max attempts, routing to operator") self._transfer_to("PJSIP/1001", "Operator (timeout)") def _transfer_to(self, endpoint: str, description: str): """Transfer the caller to an endpoint via a bridge.""" self.state = "transferring" self._update_state() try: # Create a bridge bridge = self.ari.create_bridge("mixing", f"transfer-{self.channel_id}") self.bridge_id = bridge["id"] # Add the caller to the bridge self.ari.add_to_bridge(self.bridge_id, self.channel_id) # Play ringing tone to caller self.ari.play(self.channel_id, "tone:ring", playback_id=f"ring-{self.channel_id}") # Originate a call to the target target = self.ari.dial(self.channel_id, endpoint, caller_id=self.caller_info.get("number", "")) log.info(f"Call {self.channel_id}: dialing {endpoint} " f"(bridge: {self.bridge_id})") # Store transfer info in Redis redis_client.hset(f"call:{self.channel_id}", mapping={ "state": "transferring", "transfer_to": description, "bridge_id": self.bridge_id, }) except Exception as e: log.error(f"Call {self.channel_id}: transfer failed: {e}") self.ari.play(self.channel_id, "sound:an-error-has-occurred", playback_id=f"error-{self.channel_id}") def _process_directory_input(self, digit: str): """Handle directory (spell-by-name) input.""" if digit == "*": # Go back to main menu self.state = "greeting" self._play_greeting() return self.dtmf_buffer += digit if len(self.dtmf_buffer) >= 3: # Look up by extension (simplified - real implementation would # search by name using the keypad mapping) ext = self.dtmf_buffer[:4] self.dtmf_buffer = "" log.info(f"Call {self.channel_id}: directory lookup for {ext}") self._transfer_to(f"PJSIP/{ext}", f"Directory: {ext}") def handle_playback_finished(self, playback_id: str): """Handle playback completion.""" if playback_id.startswith("greeting-"): self.state = "waiting" self._update_state() # Wait for DTMF, replay after timeout handled by dialplan def cleanup(self): """Clean up when the call ends.""" log.info(f"Call {self.channel_id}: cleanup") if self.bridge_id: try: self.ari.destroy_bridge(self.bridge_id) except Exception: pass redis_client.delete(f"call:{self.channel_id}") def _update_state(self): """Update call state in Redis.""" redis_client.hset(f"call:{self.channel_id}", "state", self.state) # =============================================================================
# WebSocket Event Handler
# ============================================================================= # Active call handlers
calls: dict[str, CallHandler] = {}
ari = ARIClient(ARI_URL, ARI_USER, ARI_PASS) def on_message(ws, message): """Handle incoming WebSocket events from Asterisk.""" try: event = json.loads(message) event_type = event.get("type", "unknown") channel = event.get("channel", {}) channel_id = channel.get("id", "") if event_type == "StasisStart": # New call entered our application caller = channel.get("caller", {}) caller_info = { "number": caller.get("number", "unknown"), "name": caller.get("name", "unknown"), } log.info(f"StasisStart: {channel_id} from {caller_info}") handler = CallHandler(ari, channel_id, caller_info) calls[channel_id] = handler # Start IVR in a separate thread to avoid blocking the event loop threading.Thread(target=handler.start, daemon=True).start() elif event_type == "StasisEnd": # Call left our application log.info(f"StasisEnd: {channel_id}") handler = calls.pop(channel_id, None) if handler: handler.cleanup() elif event_type == "ChannelDtmfReceived": # DTMF digit received digit = event.get("digit", "") handler = calls.get(channel_id) if handler: handler.handle_dtmf(digit) elif event_type == "PlaybackFinished": # Audio playback completed playback = event.get("playback", {}) playback_id = playback.get("id", "") # Find which call this playback belongs to target_channel = playback.get("target_uri", "").replace("channel:", "") handler = calls.get(target_channel) if handler: handler.handle_playback_finished(playback_id) elif event_type == "ChannelDestroyed": # Channel was destroyed (hangup) handler = calls.pop(channel_id, None) if handler: handler.cleanup() except Exception as e: log.error(f"Error handling event: {e}", exc_info=True) def on_error(ws, error): log.error(f"WebSocket error: {error}") def on_close(ws, close_status_code, close_msg): log.warning(f"WebSocket closed: {close_status_code} - {close_msg}") def on_open(ws): log.info("WebSocket connected to Asterisk ARI") # =============================================================================
# Main
# ============================================================================= def main(): """Connect to ARI WebSocket and handle events.""" log.info(f"Starting ARI Auto-Attendant") log.info(f"ARI URL: {ARI_URL}") log.info(f"Application: {ARI_APP}") # Verify ARI is reachable try: info = ari.get("/asterisk/info") log.info(f"Connected to Asterisk {info.get('system', {}).get('version', 'unknown')}") except Exception as e: log.error(f"Cannot connect to ARI: {e}") sys.exit(1) # Build WebSocket URL ws_url = ARI_URL.replace("http://", "ws://").replace("https://", "wss://") ws_url = f"{ws_url}/ari/events?api_key={ARI_USER}:{ARI_PASS}&app={ARI_APP}" # Connect with auto-reconnect while True: try: ws = websocket.WebSocketApp( ws_url, on_open=on_open, on_message=on_message, on_error=on_error, on_close=on_close, ) ws.run_forever(ping_interval=30, ping_timeout=10) except KeyboardInterrupt: log.info("Shutting down...") break except Exception as e: log.error(f"WebSocket connection failed: {e}") log.info("Reconnecting in 5 seconds...") time.sleep(5) if __name__ == "__main__": main()
#!/usr/bin/env python3
"""
Asterisk ARI Auto-Attendant Application
Handles inbound calls via REST API + WebSocket events. Requires: - Asterisk with ARI enabled (http.conf + ari.conf) - Dialplan: exten => _X.,1,Stasis(autoattendant,${EXTEN}) - pip install requests websocket-client redis
""" import json
import logging
import os
import sys
import threading
import time
from typing import Any import redis
import requests
import websocket # =============================================================================
# Configuration
# ============================================================================= ARI_URL = os.getenv("ARI_URL", "http://asterisk:8088")
ARI_USER = os.getenv("ARI_USERNAME", "ari_user")
ARI_PASS = os.getenv("ARI_PASSWORD", "Ar1_S3cur3_2026!")
ARI_APP = os.getenv("ARI_APP", "autoattendant") REDIS_HOST = os.getenv("REDIS_HOST", "redis")
REDIS_PORT = int(os.getenv("REDIS_PORT", "6379"))
REDIS_PASS = os.getenv("REDIS_PASSWORD", "R3d1s_S3cur3_2026!") # Logging
logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s", datefmt="%Y-%m-%d %H:%M:%S",
)
log = logging.getLogger("ari-autoattendant") # =============================================================================
# Redis Client (for state tracking)
# ============================================================================= redis_client = redis.Redis( host=REDIS_HOST, port=REDIS_PORT, password=REDIS_PASS, decode_responses=True,
) # =============================================================================
# ARI REST Client
# ============================================================================= class ARIClient: """Thin wrapper around the ARI REST API.""" def __init__(self, base_url: str, username: str, password: str): self.base_url = base_url.rstrip("/") self.auth = (username, password) self.session = requests.Session() self.session.auth = self.auth def get(self, path: str, **kwargs) -> Any: resp = self.session.get(f"{self.base_url}/ari{path}", **kwargs) resp.raise_for_status() return resp.json() if resp.content else None def post(self, path: str, **kwargs) -> Any: resp = self.session.post(f"{self.base_url}/ari{path}", **kwargs) resp.raise_for_status() return resp.json() if resp.content else None def delete(self, path: str, **kwargs) -> Any: resp = self.session.delete(f"{self.base_url}/ari{path}", **kwargs) resp.raise_for_status() return resp.json() if resp.content else None # ---- Channel Operations ---- def answer(self, channel_id: str): """Answer a channel.""" return self.post(f"/channels/{channel_id}/answer") def hangup(self, channel_id: str, reason: str = "normal"): """Hang up a channel.""" return self.delete(f"/channels/{channel_id}", params={"reason": reason}) def play(self, channel_id: str, media: str, playback_id: str = None): """Play media on a channel. Media format: 'sound:filename' or 'tone:dial'.""" params = {"media": media} if playback_id: params["playbackId"] = playback_id return self.post(f"/channels/{channel_id}/play", params=params) def dial(self, channel_id: str, endpoint: str, context: str = None, caller_id: str = None, timeout: int = 30): """Originate a call and bridge it.""" params = { "endpoint": endpoint, "app": ARI_APP, "timeout": timeout, } if caller_id: params["callerId"] = caller_id return self.post("/channels", params=params) # ---- Bridge Operations ---- def create_bridge(self, bridge_type: str = "mixing", name: str = None): """Create a bridge (conference).""" params = {"type": bridge_type} if name: params["name"] = name return self.post("/bridges", params=params) def add_to_bridge(self, bridge_id: str, channel_id: str): """Add a channel to a bridge.""" return self.post(f"/bridges/{bridge_id}/addChannel", params={"channel": channel_id}) def remove_from_bridge(self, bridge_id: str, channel_id: str): """Remove a channel from a bridge.""" return self.post(f"/bridges/{bridge_id}/removeChannel", params={"channel": channel_id}) def destroy_bridge(self, bridge_id: str): """Destroy a bridge.""" return self.delete(f"/bridges/{bridge_id}") # =============================================================================
# Call Handler
# ============================================================================= class CallHandler: """Handles a single call through the IVR flow.""" # IVR menu options: DTMF digit -> (description, PJSIP endpoint to dial) MENU = { "1": ("Sales", "PJSIP/1001"), "2": ("Support", "PJSIP/1004"), "3": ("Directory", None), # Sub-menu "0": ("Operator", "PJSIP/1001"), } def __init__(self, ari: ARIClient, channel_id: str, caller_info: dict): self.ari = ari self.channel_id = channel_id self.caller_info = caller_info self.state = "greeting" self.dtmf_buffer = "" self.attempts = 0 self.max_attempts = 3 self.bridge_id = None # Track state in Redis redis_client.hset(f"call:{channel_id}", mapping={ "state": self.state, "caller_num": caller_info.get("number", "unknown"), "caller_name": caller_info.get("name", "unknown"), "start_time": str(int(time.time())), }) def start(self): """Begin the IVR flow.""" log.info(f"Call {self.channel_id}: answering from {self.caller_info}") self.ari.answer(self.channel_id) time.sleep(0.5) # Brief pause after answer self._play_greeting() def _play_greeting(self): """Play the main greeting.""" self.state = "greeting" self._update_state() # Play greeting - uses built-in Asterisk sounds # In production, replace with custom recordings self.ari.play(self.channel_id, "sound:custom/welcome", playback_id=f"greeting-{self.channel_id}") def handle_dtmf(self, digit: str): """Process a DTMF digit.""" log.info(f"Call {self.channel_id}: DTMF '{digit}' in state '{self.state}'") if self.state in ("greeting", "waiting"): self._process_menu_selection(digit) elif self.state == "directory": self._process_directory_input(digit) def _process_menu_selection(self, digit: str): """Handle main menu DTMF selection.""" if digit in self.MENU: description, endpoint = self.MENU[digit] log.info(f"Call {self.channel_id}: selected '{description}'") if endpoint: self._transfer_to(endpoint, description) elif digit == "3": self.state = "directory" self._update_state() self.ari.play(self.channel_id, "sound:dir-pls-enter-person", playback_id=f"directory-{self.channel_id}") elif digit == "#": # Repeat menu self._play_greeting() else: self.ari.play(self.channel_id, "sound:option-is-invalid", playback_id=f"invalid-{self.channel_id}") self.attempts += 1 if self.attempts >= self.max_attempts: log.info(f"Call {self.channel_id}: max attempts, routing to operator") self._transfer_to("PJSIP/1001", "Operator (timeout)") def _transfer_to(self, endpoint: str, description: str): """Transfer the caller to an endpoint via a bridge.""" self.state = "transferring" self._update_state() try: # Create a bridge bridge = self.ari.create_bridge("mixing", f"transfer-{self.channel_id}") self.bridge_id = bridge["id"] # Add the caller to the bridge self.ari.add_to_bridge(self.bridge_id, self.channel_id) # Play ringing tone to caller self.ari.play(self.channel_id, "tone:ring", playback_id=f"ring-{self.channel_id}") # Originate a call to the target target = self.ari.dial(self.channel_id, endpoint, caller_id=self.caller_info.get("number", "")) log.info(f"Call {self.channel_id}: dialing {endpoint} " f"(bridge: {self.bridge_id})") # Store transfer info in Redis redis_client.hset(f"call:{self.channel_id}", mapping={ "state": "transferring", "transfer_to": description, "bridge_id": self.bridge_id, }) except Exception as e: log.error(f"Call {self.channel_id}: transfer failed: {e}") self.ari.play(self.channel_id, "sound:an-error-has-occurred", playback_id=f"error-{self.channel_id}") def _process_directory_input(self, digit: str): """Handle directory (spell-by-name) input.""" if digit == "*": # Go back to main menu self.state = "greeting" self._play_greeting() return self.dtmf_buffer += digit if len(self.dtmf_buffer) >= 3: # Look up by extension (simplified - real implementation would # search by name using the keypad mapping) ext = self.dtmf_buffer[:4] self.dtmf_buffer = "" log.info(f"Call {self.channel_id}: directory lookup for {ext}") self._transfer_to(f"PJSIP/{ext}", f"Directory: {ext}") def handle_playback_finished(self, playback_id: str): """Handle playback completion.""" if playback_id.startswith("greeting-"): self.state = "waiting" self._update_state() # Wait for DTMF, replay after timeout handled by dialplan def cleanup(self): """Clean up when the call ends.""" log.info(f"Call {self.channel_id}: cleanup") if self.bridge_id: try: self.ari.destroy_bridge(self.bridge_id) except Exception: pass redis_client.delete(f"call:{self.channel_id}") def _update_state(self): """Update call state in Redis.""" redis_client.hset(f"call:{self.channel_id}", "state", self.state) # =============================================================================
# WebSocket Event Handler
# ============================================================================= # Active call handlers
calls: dict[str, CallHandler] = {}
ari = ARIClient(ARI_URL, ARI_USER, ARI_PASS) def on_message(ws, message): """Handle incoming WebSocket events from Asterisk.""" try: event = json.loads(message) event_type = event.get("type", "unknown") channel = event.get("channel", {}) channel_id = channel.get("id", "") if event_type == "StasisStart": # New call entered our application caller = channel.get("caller", {}) caller_info = { "number": caller.get("number", "unknown"), "name": caller.get("name", "unknown"), } log.info(f"StasisStart: {channel_id} from {caller_info}") handler = CallHandler(ari, channel_id, caller_info) calls[channel_id] = handler # Start IVR in a separate thread to avoid blocking the event loop threading.Thread(target=handler.start, daemon=True).start() elif event_type == "StasisEnd": # Call left our application log.info(f"StasisEnd: {channel_id}") handler = calls.pop(channel_id, None) if handler: handler.cleanup() elif event_type == "ChannelDtmfReceived": # DTMF digit received digit = event.get("digit", "") handler = calls.get(channel_id) if handler: handler.handle_dtmf(digit) elif event_type == "PlaybackFinished": # Audio playback completed playback = event.get("playback", {}) playback_id = playback.get("id", "") # Find which call this playback belongs to target_channel = playback.get("target_uri", "").replace("channel:", "") handler = calls.get(target_channel) if handler: handler.handle_playback_finished(playback_id) elif event_type == "ChannelDestroyed": # Channel was destroyed (hangup) handler = calls.pop(channel_id, None) if handler: handler.cleanup() except Exception as e: log.error(f"Error handling event: {e}", exc_info=True) def on_error(ws, error): log.error(f"WebSocket error: {error}") def on_close(ws, close_status_code, close_msg): log.warning(f"WebSocket closed: {close_status_code} - {close_msg}") def on_open(ws): log.info("WebSocket connected to Asterisk ARI") # =============================================================================
# Main
# ============================================================================= def main(): """Connect to ARI WebSocket and handle events.""" log.info(f"Starting ARI Auto-Attendant") log.info(f"ARI URL: {ARI_URL}") log.info(f"Application: {ARI_APP}") # Verify ARI is reachable try: info = ari.get("/asterisk/info") log.info(f"Connected to Asterisk {info.get('system', {}).get('version', 'unknown')}") except Exception as e: log.error(f"Cannot connect to ARI: {e}") sys.exit(1) # Build WebSocket URL ws_url = ARI_URL.replace("http://", "ws://").replace("https://", "wss://") ws_url = f"{ws_url}/ari/events?api_key={ARI_USER}:{ARI_PASS}&app={ARI_APP}" # Connect with auto-reconnect while True: try: ws = websocket.WebSocketApp( ws_url, on_open=on_open, on_message=on_message, on_error=on_error, on_close=on_close, ) ws.run_forever(ping_interval=30, ping_timeout=10) except KeyboardInterrupt: log.info("Shutting down...") break except Exception as e: log.error(f"WebSocket connection failed: {e}") log.info("Reconnecting in 5 seconds...") time.sleep(5) if __name__ == "__main__": main()
#!/usr/bin/env python3
"""
Asterisk ARI Auto-Attendant Application
Handles inbound calls via REST API + WebSocket events. Requires: - Asterisk with ARI enabled (http.conf + ari.conf) - Dialplan: exten => _X.,1,Stasis(autoattendant,${EXTEN}) - pip install requests websocket-client redis
""" import json
import logging
import os
import sys
import threading
import time
from typing import Any import redis
import requests
import websocket # =============================================================================
# Configuration
# ============================================================================= ARI_URL = os.getenv("ARI_URL", "http://asterisk:8088")
ARI_USER = os.getenv("ARI_USERNAME", "ari_user")
ARI_PASS = os.getenv("ARI_PASSWORD", "Ar1_S3cur3_2026!")
ARI_APP = os.getenv("ARI_APP", "autoattendant") REDIS_HOST = os.getenv("REDIS_HOST", "redis")
REDIS_PORT = int(os.getenv("REDIS_PORT", "6379"))
REDIS_PASS = os.getenv("REDIS_PASSWORD", "R3d1s_S3cur3_2026!") # Logging
logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s", datefmt="%Y-%m-%d %H:%M:%S",
)
log = logging.getLogger("ari-autoattendant") # =============================================================================
# Redis Client (for state tracking)
# ============================================================================= redis_client = redis.Redis( host=REDIS_HOST, port=REDIS_PORT, password=REDIS_PASS, decode_responses=True,
) # =============================================================================
# ARI REST Client
# ============================================================================= class ARIClient: """Thin wrapper around the ARI REST API.""" def __init__(self, base_url: str, username: str, password: str): self.base_url = base_url.rstrip("/") self.auth = (username, password) self.session = requests.Session() self.session.auth = self.auth def get(self, path: str, **kwargs) -> Any: resp = self.session.get(f"{self.base_url}/ari{path}", **kwargs) resp.raise_for_status() return resp.json() if resp.content else None def post(self, path: str, **kwargs) -> Any: resp = self.session.post(f"{self.base_url}/ari{path}", **kwargs) resp.raise_for_status() return resp.json() if resp.content else None def delete(self, path: str, **kwargs) -> Any: resp = self.session.delete(f"{self.base_url}/ari{path}", **kwargs) resp.raise_for_status() return resp.json() if resp.content else None # ---- Channel Operations ---- def answer(self, channel_id: str): """Answer a channel.""" return self.post(f"/channels/{channel_id}/answer") def hangup(self, channel_id: str, reason: str = "normal"): """Hang up a channel.""" return self.delete(f"/channels/{channel_id}", params={"reason": reason}) def play(self, channel_id: str, media: str, playback_id: str = None): """Play media on a channel. Media format: 'sound:filename' or 'tone:dial'.""" params = {"media": media} if playback_id: params["playbackId"] = playback_id return self.post(f"/channels/{channel_id}/play", params=params) def dial(self, channel_id: str, endpoint: str, context: str = None, caller_id: str = None, timeout: int = 30): """Originate a call and bridge it.""" params = { "endpoint": endpoint, "app": ARI_APP, "timeout": timeout, } if caller_id: params["callerId"] = caller_id return self.post("/channels", params=params) # ---- Bridge Operations ---- def create_bridge(self, bridge_type: str = "mixing", name: str = None): """Create a bridge (conference).""" params = {"type": bridge_type} if name: params["name"] = name return self.post("/bridges", params=params) def add_to_bridge(self, bridge_id: str, channel_id: str): """Add a channel to a bridge.""" return self.post(f"/bridges/{bridge_id}/addChannel", params={"channel": channel_id}) def remove_from_bridge(self, bridge_id: str, channel_id: str): """Remove a channel from a bridge.""" return self.post(f"/bridges/{bridge_id}/removeChannel", params={"channel": channel_id}) def destroy_bridge(self, bridge_id: str): """Destroy a bridge.""" return self.delete(f"/bridges/{bridge_id}") # =============================================================================
# Call Handler
# ============================================================================= class CallHandler: """Handles a single call through the IVR flow.""" # IVR menu options: DTMF digit -> (description, PJSIP endpoint to dial) MENU = { "1": ("Sales", "PJSIP/1001"), "2": ("Support", "PJSIP/1004"), "3": ("Directory", None), # Sub-menu "0": ("Operator", "PJSIP/1001"), } def __init__(self, ari: ARIClient, channel_id: str, caller_info: dict): self.ari = ari self.channel_id = channel_id self.caller_info = caller_info self.state = "greeting" self.dtmf_buffer = "" self.attempts = 0 self.max_attempts = 3 self.bridge_id = None # Track state in Redis redis_client.hset(f"call:{channel_id}", mapping={ "state": self.state, "caller_num": caller_info.get("number", "unknown"), "caller_name": caller_info.get("name", "unknown"), "start_time": str(int(time.time())), }) def start(self): """Begin the IVR flow.""" log.info(f"Call {self.channel_id}: answering from {self.caller_info}") self.ari.answer(self.channel_id) time.sleep(0.5) # Brief pause after answer self._play_greeting() def _play_greeting(self): """Play the main greeting.""" self.state = "greeting" self._update_state() # Play greeting - uses built-in Asterisk sounds # In production, replace with custom recordings self.ari.play(self.channel_id, "sound:custom/welcome", playback_id=f"greeting-{self.channel_id}") def handle_dtmf(self, digit: str): """Process a DTMF digit.""" log.info(f"Call {self.channel_id}: DTMF '{digit}' in state '{self.state}'") if self.state in ("greeting", "waiting"): self._process_menu_selection(digit) elif self.state == "directory": self._process_directory_input(digit) def _process_menu_selection(self, digit: str): """Handle main menu DTMF selection.""" if digit in self.MENU: description, endpoint = self.MENU[digit] log.info(f"Call {self.channel_id}: selected '{description}'") if endpoint: self._transfer_to(endpoint, description) elif digit == "3": self.state = "directory" self._update_state() self.ari.play(self.channel_id, "sound:dir-pls-enter-person", playback_id=f"directory-{self.channel_id}") elif digit == "#": # Repeat menu self._play_greeting() else: self.ari.play(self.channel_id, "sound:option-is-invalid", playback_id=f"invalid-{self.channel_id}") self.attempts += 1 if self.attempts >= self.max_attempts: log.info(f"Call {self.channel_id}: max attempts, routing to operator") self._transfer_to("PJSIP/1001", "Operator (timeout)") def _transfer_to(self, endpoint: str, description: str): """Transfer the caller to an endpoint via a bridge.""" self.state = "transferring" self._update_state() try: # Create a bridge bridge = self.ari.create_bridge("mixing", f"transfer-{self.channel_id}") self.bridge_id = bridge["id"] # Add the caller to the bridge self.ari.add_to_bridge(self.bridge_id, self.channel_id) # Play ringing tone to caller self.ari.play(self.channel_id, "tone:ring", playback_id=f"ring-{self.channel_id}") # Originate a call to the target target = self.ari.dial(self.channel_id, endpoint, caller_id=self.caller_info.get("number", "")) log.info(f"Call {self.channel_id}: dialing {endpoint} " f"(bridge: {self.bridge_id})") # Store transfer info in Redis redis_client.hset(f"call:{self.channel_id}", mapping={ "state": "transferring", "transfer_to": description, "bridge_id": self.bridge_id, }) except Exception as e: log.error(f"Call {self.channel_id}: transfer failed: {e}") self.ari.play(self.channel_id, "sound:an-error-has-occurred", playback_id=f"error-{self.channel_id}") def _process_directory_input(self, digit: str): """Handle directory (spell-by-name) input.""" if digit == "*": # Go back to main menu self.state = "greeting" self._play_greeting() return self.dtmf_buffer += digit if len(self.dtmf_buffer) >= 3: # Look up by extension (simplified - real implementation would # search by name using the keypad mapping) ext = self.dtmf_buffer[:4] self.dtmf_buffer = "" log.info(f"Call {self.channel_id}: directory lookup for {ext}") self._transfer_to(f"PJSIP/{ext}", f"Directory: {ext}") def handle_playback_finished(self, playback_id: str): """Handle playback completion.""" if playback_id.startswith("greeting-"): self.state = "waiting" self._update_state() # Wait for DTMF, replay after timeout handled by dialplan def cleanup(self): """Clean up when the call ends.""" log.info(f"Call {self.channel_id}: cleanup") if self.bridge_id: try: self.ari.destroy_bridge(self.bridge_id) except Exception: pass redis_client.delete(f"call:{self.channel_id}") def _update_state(self): """Update call state in Redis.""" redis_client.hset(f"call:{self.channel_id}", "state", self.state) # =============================================================================
# WebSocket Event Handler
# ============================================================================= # Active call handlers
calls: dict[str, CallHandler] = {}
ari = ARIClient(ARI_URL, ARI_USER, ARI_PASS) def on_message(ws, message): """Handle incoming WebSocket events from Asterisk.""" try: event = json.loads(message) event_type = event.get("type", "unknown") channel = event.get("channel", {}) channel_id = channel.get("id", "") if event_type == "StasisStart": # New call entered our application caller = channel.get("caller", {}) caller_info = { "number": caller.get("number", "unknown"), "name": caller.get("name", "unknown"), } log.info(f"StasisStart: {channel_id} from {caller_info}") handler = CallHandler(ari, channel_id, caller_info) calls[channel_id] = handler # Start IVR in a separate thread to avoid blocking the event loop threading.Thread(target=handler.start, daemon=True).start() elif event_type == "StasisEnd": # Call left our application log.info(f"StasisEnd: {channel_id}") handler = calls.pop(channel_id, None) if handler: handler.cleanup() elif event_type == "ChannelDtmfReceived": # DTMF digit received digit = event.get("digit", "") handler = calls.get(channel_id) if handler: handler.handle_dtmf(digit) elif event_type == "PlaybackFinished": # Audio playback completed playback = event.get("playback", {}) playback_id = playback.get("id", "") # Find which call this playback belongs to target_channel = playback.get("target_uri", "").replace("channel:", "") handler = calls.get(target_channel) if handler: handler.handle_playback_finished(playback_id) elif event_type == "ChannelDestroyed": # Channel was destroyed (hangup) handler = calls.pop(channel_id, None) if handler: handler.cleanup() except Exception as e: log.error(f"Error handling event: {e}", exc_info=True) def on_error(ws, error): log.error(f"WebSocket error: {error}") def on_close(ws, close_status_code, close_msg): log.warning(f"WebSocket closed: {close_status_code} - {close_msg}") def on_open(ws): log.info("WebSocket connected to Asterisk ARI") # =============================================================================
# Main
# ============================================================================= def main(): """Connect to ARI WebSocket and handle events.""" log.info(f"Starting ARI Auto-Attendant") log.info(f"ARI URL: {ARI_URL}") log.info(f"Application: {ARI_APP}") # Verify ARI is reachable try: info = ari.get("/asterisk/info") log.info(f"Connected to Asterisk {info.get('system', {}).get('version', 'unknown')}") except Exception as e: log.error(f"Cannot connect to ARI: {e}") sys.exit(1) # Build WebSocket URL ws_url = ARI_URL.replace("http://", "ws://").replace("https://", "wss://") ws_url = f"{ws_url}/ari/events?api_key={ARI_USER}:{ARI_PASS}&app={ARI_APP}" # Connect with auto-reconnect while True: try: ws = websocket.WebSocketApp( ws_url, on_open=on_open, on_message=on_message, on_error=on_error, on_close=on_close, ) ws.run_forever(ping_interval=30, ping_timeout=10) except KeyboardInterrupt: log.info("Shutting down...") break except Exception as e: log.error(f"WebSocket connection failed: {e}") log.info("Reconnecting in 5 seconds...") time.sleep(5) if __name__ == "__main__": main()
# --------------------------------------------------------------------------- # ARI Application - Auto-Attendant # --------------------------------------------------------------------------- ari-app: build: context: ./ari-app dockerfile: Dockerfile container_name: asterisk-ari-app restart: unless-stopped networks: - asterisk-net environment: - ARI_URL=http://asterisk:8088 - ARI_USERNAME=${ARI_USERNAME} - ARI_PASSWORD=${ARI_PASSWORD} - ARI_APP=autoattendant - REDIS_HOST=${REDIS_HOST:-redis} - REDIS_PORT=${REDIS_PORT:-6379} - REDIS_PASSWORD=${REDIS_PASSWORD} depends_on: asterisk: condition: service_healthy redis: condition: service_healthy deploy: resources: limits: cpus: '0.5' memory: 256M
# --------------------------------------------------------------------------- # ARI Application - Auto-Attendant # --------------------------------------------------------------------------- ari-app: build: context: ./ari-app dockerfile: Dockerfile container_name: asterisk-ari-app restart: unless-stopped networks: - asterisk-net environment: - ARI_URL=http://asterisk:8088 - ARI_USERNAME=${ARI_USERNAME} - ARI_PASSWORD=${ARI_PASSWORD} - ARI_APP=autoattendant - REDIS_HOST=${REDIS_HOST:-redis} - REDIS_PORT=${REDIS_PORT:-6379} - REDIS_PASSWORD=${REDIS_PASSWORD} depends_on: asterisk: condition: service_healthy redis: condition: service_healthy deploy: resources: limits: cpus: '0.5' memory: 256M
# --------------------------------------------------------------------------- # ARI Application - Auto-Attendant # --------------------------------------------------------------------------- ari-app: build: context: ./ari-app dockerfile: Dockerfile container_name: asterisk-ari-app restart: unless-stopped networks: - asterisk-net environment: - ARI_URL=http://asterisk:8088 - ARI_USERNAME=${ARI_USERNAME} - ARI_PASSWORD=${ARI_PASSWORD} - ARI_APP=autoattendant - REDIS_HOST=${REDIS_HOST:-redis} - REDIS_PORT=${REDIS_PORT:-6379} - REDIS_PASSWORD=${REDIS_PASSWORD} depends_on: asterisk: condition: service_healthy redis: condition: service_healthy deploy: resources: limits: cpus: '0.5' memory: 256M
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
CMD ["python", "-u", "app.py"]
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
CMD ["python", "-u", "app.py"]
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
CMD ["python", "-u", "app.py"]
# Verify ARI is running
curl -s -u ari_user:Ar1_S3cur3_2026! http://localhost:8088/ari/asterisk/info | jq . # List registered applications
curl -s -u ari_user:Ar1_S3cur3_2026! http://localhost:8088/ari/applications | jq . # List active channels
curl -s -u ari_user:Ar1_S3cur3_2026! http://localhost:8088/ari/channels | jq . # Originate a test call (rings extension 1001)
curl -s -X POST -u ari_user:Ar1_S3cur3_2026! \ "http://localhost:8088/ari/channels?endpoint=PJSIP/1001&app=autoattendant&callerId=Test+Call+<9999>" # Watch ARI events in real-time
wscat -c "ws://localhost:8088/ari/events?api_key=ari_user:Ar1_S3cur3_2026!&app=autoattendant"
# Verify ARI is running
curl -s -u ari_user:Ar1_S3cur3_2026! http://localhost:8088/ari/asterisk/info | jq . # List registered applications
curl -s -u ari_user:Ar1_S3cur3_2026! http://localhost:8088/ari/applications | jq . # List active channels
curl -s -u ari_user:Ar1_S3cur3_2026! http://localhost:8088/ari/channels | jq . # Originate a test call (rings extension 1001)
curl -s -X POST -u ari_user:Ar1_S3cur3_2026! \ "http://localhost:8088/ari/channels?endpoint=PJSIP/1001&app=autoattendant&callerId=Test+Call+<9999>" # Watch ARI events in real-time
wscat -c "ws://localhost:8088/ari/events?api_key=ari_user:Ar1_S3cur3_2026!&app=autoattendant"
# Verify ARI is running
curl -s -u ari_user:Ar1_S3cur3_2026! http://localhost:8088/ari/asterisk/info | jq . # List registered applications
curl -s -u ari_user:Ar1_S3cur3_2026! http://localhost:8088/ari/applications | jq . # List active channels
curl -s -u ari_user:Ar1_S3cur3_2026! http://localhost:8088/ari/channels | jq . # Originate a test call (rings extension 1001)
curl -s -X POST -u ari_user:Ar1_S3cur3_2026! \ "http://localhost:8088/ari/channels?endpoint=PJSIP/1001&app=autoattendant&callerId=Test+Call+<9999>" # Watch ARI events in real-time
wscat -c "ws://localhost:8088/ari/events?api_key=ari_user:Ar1_S3cur3_2026!&app=autoattendant"
; Transport - must use WSS (not WS) for production
[transport-wss]
type = transport
protocol = wss
bind = 0.0.0.0:8089 ; Endpoint template for WebRTC
[endpoint-webrtc](!)
type = endpoint
webrtc = yes ; Shortcut that enables: ; use_avpf = yes ; media_encryption = dtls ; dtls_verify = fingerprint ; dtls_setup = actpass ; ice_support = yes ; media_use_received_transport = yes ; rtcp_mux = yes
dtls_auto_generate_cert = yes ; Auto-generate DTLS cert (simplest)
; Transport - must use WSS (not WS) for production
[transport-wss]
type = transport
protocol = wss
bind = 0.0.0.0:8089 ; Endpoint template for WebRTC
[endpoint-webrtc](!)
type = endpoint
webrtc = yes ; Shortcut that enables: ; use_avpf = yes ; media_encryption = dtls ; dtls_verify = fingerprint ; dtls_setup = actpass ; ice_support = yes ; media_use_received_transport = yes ; rtcp_mux = yes
dtls_auto_generate_cert = yes ; Auto-generate DTLS cert (simplest)
; Transport - must use WSS (not WS) for production
[transport-wss]
type = transport
protocol = wss
bind = 0.0.0.0:8089 ; Endpoint template for WebRTC
[endpoint-webrtc](!)
type = endpoint
webrtc = yes ; Shortcut that enables: ; use_avpf = yes ; media_encryption = dtls ; dtls_verify = fingerprint ; dtls_setup = actpass ; ice_support = yes ; media_use_received_transport = yes ; rtcp_mux = yes
dtls_auto_generate_cert = yes ; Auto-generate DTLS cert (simplest)
# =============================================================================
# Coturn TURN/STUN Server Configuration
# Required for WebRTC clients behind NAT
# ============================================================================= # Network settings
listening-port=3478
# TLS listening port
tls-listening-port=5349 # Relay port range (for media relay)
min-port=49152
max-port=65535 # External IP (your server's public IP)
external-ip=YOUR_SERVER_IP # Realm
realm=YOUR_DOMAIN # Authentication
# Use long-term credentials with a shared secret
# The secret must match what your web client uses to generate credentials
use-auth-secret
static-auth-secret=T0rn_Sh4r3d_S3cr3t_2026! # TLS certificates (shared with Let's Encrypt)
cert=/etc/certs/live/YOUR_DOMAIN/fullchain.pem
pkey=/etc/certs/live/YOUR_DOMAIN/privkey.pem # Logging
log-file=stdout
verbose # Performance
total-quota=100
stale-nonce=600
max-bps=1000000 # Security
no-multicast-peers
no-cli
denied-peer-ip=10.0.0.0-10.255.255.255
denied-peer-ip=172.16.0.0-172.31.255.255
denied-peer-ip=192.168.0.0-192.168.255.255
denied-peer-ip=127.0.0.0-127.255.255.255 # Only allow UDP and TCP relay
no-udp-relay=false
no-tcp-relay=false # Fingerprint for STUN messages (recommended)
fingerprint # Enable channel binding lifetime management
channel-lifetime=600
# =============================================================================
# Coturn TURN/STUN Server Configuration
# Required for WebRTC clients behind NAT
# ============================================================================= # Network settings
listening-port=3478
# TLS listening port
tls-listening-port=5349 # Relay port range (for media relay)
min-port=49152
max-port=65535 # External IP (your server's public IP)
external-ip=YOUR_SERVER_IP # Realm
realm=YOUR_DOMAIN # Authentication
# Use long-term credentials with a shared secret
# The secret must match what your web client uses to generate credentials
use-auth-secret
static-auth-secret=T0rn_Sh4r3d_S3cr3t_2026! # TLS certificates (shared with Let's Encrypt)
cert=/etc/certs/live/YOUR_DOMAIN/fullchain.pem
pkey=/etc/certs/live/YOUR_DOMAIN/privkey.pem # Logging
log-file=stdout
verbose # Performance
total-quota=100
stale-nonce=600
max-bps=1000000 # Security
no-multicast-peers
no-cli
denied-peer-ip=10.0.0.0-10.255.255.255
denied-peer-ip=172.16.0.0-172.31.255.255
denied-peer-ip=192.168.0.0-192.168.255.255
denied-peer-ip=127.0.0.0-127.255.255.255 # Only allow UDP and TCP relay
no-udp-relay=false
no-tcp-relay=false # Fingerprint for STUN messages (recommended)
fingerprint # Enable channel binding lifetime management
channel-lifetime=600
# =============================================================================
# Coturn TURN/STUN Server Configuration
# Required for WebRTC clients behind NAT
# ============================================================================= # Network settings
listening-port=3478
# TLS listening port
tls-listening-port=5349 # Relay port range (for media relay)
min-port=49152
max-port=65535 # External IP (your server's public IP)
external-ip=YOUR_SERVER_IP # Realm
realm=YOUR_DOMAIN # Authentication
# Use long-term credentials with a shared secret
# The secret must match what your web client uses to generate credentials
use-auth-secret
static-auth-secret=T0rn_Sh4r3d_S3cr3t_2026! # TLS certificates (shared with Let's Encrypt)
cert=/etc/certs/live/YOUR_DOMAIN/fullchain.pem
pkey=/etc/certs/live/YOUR_DOMAIN/privkey.pem # Logging
log-file=stdout
verbose # Performance
total-quota=100
stale-nonce=600
max-bps=1000000 # Security
no-multicast-peers
no-cli
denied-peer-ip=10.0.0.0-10.255.255.255
denied-peer-ip=172.16.0.0-172.31.255.255
denied-peer-ip=192.168.0.0-192.168.255.255
denied-peer-ip=127.0.0.0-127.255.255.255 # Only allow UDP and TCP relay
no-udp-relay=false
no-tcp-relay=false # Fingerprint for STUN messages (recommended)
fingerprint # Enable channel binding lifetime management
channel-lifetime=600
# =============================================================================
# Nginx Configuration - Asterisk Reverse Proxy
# ============================================================================= user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid; events { worker_connections 1024;
} http { include /etc/nginx/mime.types; default_type application/octet-stream; log_format main '$remote_addr - $remote_user [$time_local] "$request" ' '$status $body_bytes_sent "$http_referer" ' '"$http_user_agent"'; access_log /var/log/nginx/access.log main; sendfile on; tcp_nopush on; keepalive_timeout 65; client_max_body_size 50M; # WebSocket upgrade map map $http_upgrade $connection_upgrade { default upgrade; '' close; } # HTTP -> HTTPS redirect server { listen 80; server_name YOUR_DOMAIN; # ACME challenge for Let's Encrypt location /.well-known/acme-challenge/ { root /var/www/certbot; } # Health check endpoint location /health { access_log off; return 200 "OK\n"; add_header Content-Type text/plain; } # Redirect everything else to HTTPS location / { return 301 https://$host$request_uri; } } # HTTPS server server { listen 443 ssl http2; server_name YOUR_DOMAIN; # TLS certificates (from Let's Encrypt via certbot) ssl_certificate /etc/nginx/certs/live/YOUR_DOMAIN/fullchain.pem; ssl_certificate_key /etc/nginx/certs/live/YOUR_DOMAIN/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384; ssl_prefer_server_ciphers off; ssl_session_timeout 1d; ssl_session_cache shared:SSL:10m; # HSTS add_header Strict-Transport-Security "max-age=63072000" always; # ARI REST API proxy location /ari/ { proxy_pass http://asterisk:8088/ari/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } # ARI WebSocket proxy location /ari/events { proxy_pass http://asterisk:8088/ari/events; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_read_timeout 86400; proxy_send_timeout 86400; } # SIP WebSocket proxy (for WebRTC SIP.js clients) location /ws { proxy_pass http://asterisk:8088/ws; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_read_timeout 86400; proxy_send_timeout 86400; } # Static web phone files (optional) location / { root /var/www/webphone; index index.html; try_files $uri $uri/ =404; } # Health check location /health { access_log off; return 200 "OK\n"; add_header Content-Type text/plain; } }
}
# =============================================================================
# Nginx Configuration - Asterisk Reverse Proxy
# ============================================================================= user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid; events { worker_connections 1024;
} http { include /etc/nginx/mime.types; default_type application/octet-stream; log_format main '$remote_addr - $remote_user [$time_local] "$request" ' '$status $body_bytes_sent "$http_referer" ' '"$http_user_agent"'; access_log /var/log/nginx/access.log main; sendfile on; tcp_nopush on; keepalive_timeout 65; client_max_body_size 50M; # WebSocket upgrade map map $http_upgrade $connection_upgrade { default upgrade; '' close; } # HTTP -> HTTPS redirect server { listen 80; server_name YOUR_DOMAIN; # ACME challenge for Let's Encrypt location /.well-known/acme-challenge/ { root /var/www/certbot; } # Health check endpoint location /health { access_log off; return 200 "OK\n"; add_header Content-Type text/plain; } # Redirect everything else to HTTPS location / { return 301 https://$host$request_uri; } } # HTTPS server server { listen 443 ssl http2; server_name YOUR_DOMAIN; # TLS certificates (from Let's Encrypt via certbot) ssl_certificate /etc/nginx/certs/live/YOUR_DOMAIN/fullchain.pem; ssl_certificate_key /etc/nginx/certs/live/YOUR_DOMAIN/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384; ssl_prefer_server_ciphers off; ssl_session_timeout 1d; ssl_session_cache shared:SSL:10m; # HSTS add_header Strict-Transport-Security "max-age=63072000" always; # ARI REST API proxy location /ari/ { proxy_pass http://asterisk:8088/ari/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } # ARI WebSocket proxy location /ari/events { proxy_pass http://asterisk:8088/ari/events; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_read_timeout 86400; proxy_send_timeout 86400; } # SIP WebSocket proxy (for WebRTC SIP.js clients) location /ws { proxy_pass http://asterisk:8088/ws; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_read_timeout 86400; proxy_send_timeout 86400; } # Static web phone files (optional) location / { root /var/www/webphone; index index.html; try_files $uri $uri/ =404; } # Health check location /health { access_log off; return 200 "OK\n"; add_header Content-Type text/plain; } }
}
# =============================================================================
# Nginx Configuration - Asterisk Reverse Proxy
# ============================================================================= user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid; events { worker_connections 1024;
} http { include /etc/nginx/mime.types; default_type application/octet-stream; log_format main '$remote_addr - $remote_user [$time_local] "$request" ' '$status $body_bytes_sent "$http_referer" ' '"$http_user_agent"'; access_log /var/log/nginx/access.log main; sendfile on; tcp_nopush on; keepalive_timeout 65; client_max_body_size 50M; # WebSocket upgrade map map $http_upgrade $connection_upgrade { default upgrade; '' close; } # HTTP -> HTTPS redirect server { listen 80; server_name YOUR_DOMAIN; # ACME challenge for Let's Encrypt location /.well-known/acme-challenge/ { root /var/www/certbot; } # Health check endpoint location /health { access_log off; return 200 "OK\n"; add_header Content-Type text/plain; } # Redirect everything else to HTTPS location / { return 301 https://$host$request_uri; } } # HTTPS server server { listen 443 ssl http2; server_name YOUR_DOMAIN; # TLS certificates (from Let's Encrypt via certbot) ssl_certificate /etc/nginx/certs/live/YOUR_DOMAIN/fullchain.pem; ssl_certificate_key /etc/nginx/certs/live/YOUR_DOMAIN/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384; ssl_prefer_server_ciphers off; ssl_session_timeout 1d; ssl_session_cache shared:SSL:10m; # HSTS add_header Strict-Transport-Security "max-age=63072000" always; # ARI REST API proxy location /ari/ { proxy_pass http://asterisk:8088/ari/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } # ARI WebSocket proxy location /ari/events { proxy_pass http://asterisk:8088/ari/events; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_read_timeout 86400; proxy_send_timeout 86400; } # SIP WebSocket proxy (for WebRTC SIP.js clients) location /ws { proxy_pass http://asterisk:8088/ws; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_read_timeout 86400; proxy_send_timeout 86400; } # Static web phone files (optional) location / { root /var/www/webphone; index index.html; try_files $uri $uri/ =404; } # Health check location /health { access_log off; return 200 "OK\n"; add_header Content-Type text/plain; } }
}
# Start just Nginx (for ACME challenge)
docker compose up -d nginx # Request certificate
docker compose run --rm certbot certonly \ --webroot \ --webroot-path /var/www/certbot \ -d YOUR_DOMAIN \ --email admin@YOUR_DOMAIN \ --agree-tos \ --no-eff-email # Restart Nginx with the new certificate
docker compose restart nginx
# Start just Nginx (for ACME challenge)
docker compose up -d nginx # Request certificate
docker compose run --rm certbot certonly \ --webroot \ --webroot-path /var/www/certbot \ -d YOUR_DOMAIN \ --email admin@YOUR_DOMAIN \ --agree-tos \ --no-eff-email # Restart Nginx with the new certificate
docker compose restart nginx
# Start just Nginx (for ACME challenge)
docker compose up -d nginx # Request certificate
docker compose run --rm certbot certonly \ --webroot \ --webroot-path /var/www/certbot \ -d YOUR_DOMAIN \ --email admin@YOUR_DOMAIN \ --agree-tos \ --no-eff-email # Restart Nginx with the new certificate
docker compose restart nginx
<!DOCTYPE html>
<html lang="en">
<head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Web Phone</title> <script src="https://unpkg.com/[email protected]/lib/platform/web/sip.js"></script> <style> * { box-sizing: border-box; margin: 0; padding: 0; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; display: flex; justify-content: center; align-items: center; min-height: 100vh; background: #1a1a2e; } .phone { background: #16213e; border-radius: 20px; padding: 30px; width: 320px; box-shadow: 0 20px 60px rgba(0,0,0,0.5); } .display { background: #0f3460; border-radius: 10px; padding: 15px; margin-bottom: 20px; text-align: center; } .display .number { color: #e94560; font-size: 24px; font-weight: bold; min-height: 36px; word-break: break-all; } .display .status { color: #a0a0a0; font-size: 12px; margin-top: 5px; } .keypad { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin-bottom: 20px; } .key { background: #0f3460; border: none; color: white; font-size: 22px; padding: 18px; border-radius: 10px; cursor: pointer; transition: background 0.2s; } .key:hover { background: #1a5276; } .key:active { background: #e94560; } .key .sub { display: block; font-size: 10px; color: #666; } .actions { display: flex; gap: 10px; } .btn { flex: 1; padding: 15px; border: none; border-radius: 10px; font-size: 16px; font-weight: bold; cursor: pointer; } .btn-call { background: #27ae60; color: white; } .btn-call:hover { background: #2ecc71; } .btn-hangup { background: #e74c3c; color: white; } .btn-hangup:hover { background: #c0392b; } .btn:disabled { opacity: 0.3; cursor: not-allowed; } .config { margin-top: 15px; padding-top: 15px; border-top: 1px solid #0f3460; } .config input { width: 100%; padding: 8px; margin: 3px 0; background: #0f3460; border: 1px solid #1a5276; color: white; border-radius: 5px; font-size: 12px; } .config label { color: #666; font-size: 11px; } .config button { width: 100%; padding: 10px; margin-top: 8px; background: #3498db; color: white; border: none; border-radius: 5px; cursor: pointer; font-size: 13px; } audio { display: none; } </style>
</head>
<body> <div class="phone"> <div class="display"> <div class="number" id="display">Ready</div> <div class="status" id="status">Not registered</div> </div> <div class="keypad"> <button class="key" onclick="press('1')">1<span class="sub"> </span></button> <button class="key" onclick="press('2')">2<span class="sub">ABC</span></button> <button class="key" onclick="press('3')">3<span class="sub">DEF</span></button> <button class="key" onclick="press('4')">4<span class="sub">GHI</span></button> <button class="key" onclick="press('5')">5<span class="sub">JKL</span></button> <button class="key" onclick="press('6')">6<span class="sub">MNO</span></button> <button class="key" onclick="press('7')">7<span class="sub">PQRS</span></button> <button class="key" onclick="press('8')">8<span class="sub">TUV</span></button> <button class="key" onclick="press('9')">9<span class="sub">WXYZ</span></button> <button class="key" onclick="press('*')">*</button> <button class="key" onclick="press('0')">0<span class="sub">+</span></button> <button class="key" onclick="press('#')">#</button> </div> <div class="actions"> <button class="btn btn-call" id="btnCall" onclick="makeCall()">Call</button> <button class="btn btn-hangup" id="btnHangup" onclick="hangUp()" disabled>Hang Up</button> </div> <div class="config" id="configPanel"> <label>Server (WSS URL)</label> <input id="cfgServer" value="wss://YOUR_DOMAIN/ws" placeholder="wss://pbx.example.com/ws"> <label>Extension</label> <input id="cfgExt" value="1010" placeholder="1010"> <label>Password</label> <input id="cfgPass" type="password" value="" placeholder="SIP password"> <label>TURN Server</label> <input id="cfgTurn" value="turn:YOUR_DOMAIN:3478" placeholder="turn:example.com:3478"> <label>TURN Password</label> <input id="cfgTurnPass" type="password" value="" placeholder="TURN shared secret"> <button onclick="register()">Register</button> </div> <audio id="remoteAudio" autoplay></audio> <audio id="localAudio" autoplay muted></audio> </div> <script> let userAgent = null; let currentSession = null; let dialedNumber = ''; const display = document.getElementById('display'); const status = document.getElementById('status'); const btnCall = document.getElementById('btnCall'); const btnHangup = document.getElementById('btnHangup'); function press(digit) { dialedNumber += digit; display.textContent = dialedNumber; // Send DTMF if in-call if (currentSession) { currentSession.sessionDescriptionHandler .sendDtmf(digit); } } function setStatus(text, isError = false) { status.textContent = text; status.style.color = isError ? '#e74c3c' : '#a0a0a0'; } function register() { const server = document.getElementById('cfgServer').value; const ext = document.getElementById('cfgExt').value; const pass = document.getElementById('cfgPass').value; const turnServer = document.getElementById('cfgTurn').value; const turnPass = document.getElementById('cfgTurnPass').value; // Extract domain from WSS URL const domain = new URL(server).hostname; setStatus('Registering...'); const transportOptions = { server: server, keepAliveInterval: 30, }; const uri = SIP.UserAgent.makeURI(`sip:${ext}@${domain}`); const userAgentOptions = { authorizationPassword: pass, authorizationUsername: ext, transportOptions: transportOptions, uri: uri, sessionDescriptionHandlerFactoryOptions: { peerConnectionConfiguration: { iceServers: [ { urls: 'stun:stun.l.google.com:19302' }, { urls: turnServer, username: 'webrtc', credential: turnPass, } ] } }, delegate: { onInvite: handleIncomingCall, } }; userAgent = new SIP.UserAgent(userAgentOptions); const registerer = new SIP.Registerer(userAgent); userAgent.start().then(() => { registerer.register(); setStatus(`Registered as ${ext}`); document.getElementById('configPanel').style.display = 'none'; }).catch(err => { setStatus(`Registration failed: ${err.message}`, true); console.error('Registration error:', err); }); } function handleIncomingCall(invitation) { const caller = invitation.remoteIdentity.displayName || invitation.remoteIdentity.uri.user || 'Unknown'; display.textContent = `Incoming: ${caller}`; setStatus('Ringing...'); // Auto-answer (in production, show accept/reject buttons) currentSession = invitation; setupSessionHandlers(invitation); invitation.accept({ sessionDescriptionHandlerOptions: { constraints: { audio: true, video: false } } }); btnCall.disabled = true; btnHangup.disabled = false; } function makeCall() { if (!userAgent || !dialedNumber) return; const domain = userAgent.configuration.uri.host; const target = SIP.UserAgent.makeURI(`sip:${dialedNumber}@${domain}`); setStatus(`Calling ${dialedNumber}...`); const inviter = new SIP.Inviter(userAgent, target, { sessionDescriptionHandlerOptions: { constraints: { audio: true, video: false } } }); currentSession = inviter; setupSessionHandlers(inviter); inviter.invite(); btnCall.disabled = true; btnHangup.disabled = false; } function setupSessionHandlers(session) { session.stateChange.addListener((state) => { switch (state) { case SIP.SessionState.Establishing: setStatus('Connecting...'); break; case SIP.SessionState.Established: setStatus('In Call'); // Attach remote audio const remoteStream = new MediaStream(); session.sessionDescriptionHandler .peerConnection.getReceivers() .forEach(receiver => { if (receiver.track) { remoteStream.addTrack(receiver.track); } }); document.getElementById('remoteAudio').srcObject = remoteStream; break; case SIP.SessionState.Terminated: setStatus('Call Ended'); display.textContent = 'Ready'; currentSession = null; dialedNumber = ''; btnCall.disabled = false; btnHangup.disabled = true; break; } }); } function hangUp() { if (!currentSession) return; switch (currentSession.state) { case SIP.SessionState.Initial: case SIP.SessionState.Establishing: currentSession.cancel(); break; case SIP.SessionState.Established: currentSession.bye(); break; } currentSession = null; dialedNumber = ''; display.textContent = 'Ready'; setStatus('Ready'); btnCall.disabled = false; btnHangup.disabled = true; } // Clear display on double-click display.addEventListener('dblclick', () => { dialedNumber = ''; display.textContent = 'Ready'; }); </script>
</body>
</html>
<!DOCTYPE html>
<html lang="en">
<head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Web Phone</title> <script src="https://unpkg.com/[email protected]/lib/platform/web/sip.js"></script> <style> * { box-sizing: border-box; margin: 0; padding: 0; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; display: flex; justify-content: center; align-items: center; min-height: 100vh; background: #1a1a2e; } .phone { background: #16213e; border-radius: 20px; padding: 30px; width: 320px; box-shadow: 0 20px 60px rgba(0,0,0,0.5); } .display { background: #0f3460; border-radius: 10px; padding: 15px; margin-bottom: 20px; text-align: center; } .display .number { color: #e94560; font-size: 24px; font-weight: bold; min-height: 36px; word-break: break-all; } .display .status { color: #a0a0a0; font-size: 12px; margin-top: 5px; } .keypad { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin-bottom: 20px; } .key { background: #0f3460; border: none; color: white; font-size: 22px; padding: 18px; border-radius: 10px; cursor: pointer; transition: background 0.2s; } .key:hover { background: #1a5276; } .key:active { background: #e94560; } .key .sub { display: block; font-size: 10px; color: #666; } .actions { display: flex; gap: 10px; } .btn { flex: 1; padding: 15px; border: none; border-radius: 10px; font-size: 16px; font-weight: bold; cursor: pointer; } .btn-call { background: #27ae60; color: white; } .btn-call:hover { background: #2ecc71; } .btn-hangup { background: #e74c3c; color: white; } .btn-hangup:hover { background: #c0392b; } .btn:disabled { opacity: 0.3; cursor: not-allowed; } .config { margin-top: 15px; padding-top: 15px; border-top: 1px solid #0f3460; } .config input { width: 100%; padding: 8px; margin: 3px 0; background: #0f3460; border: 1px solid #1a5276; color: white; border-radius: 5px; font-size: 12px; } .config label { color: #666; font-size: 11px; } .config button { width: 100%; padding: 10px; margin-top: 8px; background: #3498db; color: white; border: none; border-radius: 5px; cursor: pointer; font-size: 13px; } audio { display: none; } </style>
</head>
<body> <div class="phone"> <div class="display"> <div class="number" id="display">Ready</div> <div class="status" id="status">Not registered</div> </div> <div class="keypad"> <button class="key" onclick="press('1')">1<span class="sub"> </span></button> <button class="key" onclick="press('2')">2<span class="sub">ABC</span></button> <button class="key" onclick="press('3')">3<span class="sub">DEF</span></button> <button class="key" onclick="press('4')">4<span class="sub">GHI</span></button> <button class="key" onclick="press('5')">5<span class="sub">JKL</span></button> <button class="key" onclick="press('6')">6<span class="sub">MNO</span></button> <button class="key" onclick="press('7')">7<span class="sub">PQRS</span></button> <button class="key" onclick="press('8')">8<span class="sub">TUV</span></button> <button class="key" onclick="press('9')">9<span class="sub">WXYZ</span></button> <button class="key" onclick="press('*')">*</button> <button class="key" onclick="press('0')">0<span class="sub">+</span></button> <button class="key" onclick="press('#')">#</button> </div> <div class="actions"> <button class="btn btn-call" id="btnCall" onclick="makeCall()">Call</button> <button class="btn btn-hangup" id="btnHangup" onclick="hangUp()" disabled>Hang Up</button> </div> <div class="config" id="configPanel"> <label>Server (WSS URL)</label> <input id="cfgServer" value="wss://YOUR_DOMAIN/ws" placeholder="wss://pbx.example.com/ws"> <label>Extension</label> <input id="cfgExt" value="1010" placeholder="1010"> <label>Password</label> <input id="cfgPass" type="password" value="" placeholder="SIP password"> <label>TURN Server</label> <input id="cfgTurn" value="turn:YOUR_DOMAIN:3478" placeholder="turn:example.com:3478"> <label>TURN Password</label> <input id="cfgTurnPass" type="password" value="" placeholder="TURN shared secret"> <button onclick="register()">Register</button> </div> <audio id="remoteAudio" autoplay></audio> <audio id="localAudio" autoplay muted></audio> </div> <script> let userAgent = null; let currentSession = null; let dialedNumber = ''; const display = document.getElementById('display'); const status = document.getElementById('status'); const btnCall = document.getElementById('btnCall'); const btnHangup = document.getElementById('btnHangup'); function press(digit) { dialedNumber += digit; display.textContent = dialedNumber; // Send DTMF if in-call if (currentSession) { currentSession.sessionDescriptionHandler .sendDtmf(digit); } } function setStatus(text, isError = false) { status.textContent = text; status.style.color = isError ? '#e74c3c' : '#a0a0a0'; } function register() { const server = document.getElementById('cfgServer').value; const ext = document.getElementById('cfgExt').value; const pass = document.getElementById('cfgPass').value; const turnServer = document.getElementById('cfgTurn').value; const turnPass = document.getElementById('cfgTurnPass').value; // Extract domain from WSS URL const domain = new URL(server).hostname; setStatus('Registering...'); const transportOptions = { server: server, keepAliveInterval: 30, }; const uri = SIP.UserAgent.makeURI(`sip:${ext}@${domain}`); const userAgentOptions = { authorizationPassword: pass, authorizationUsername: ext, transportOptions: transportOptions, uri: uri, sessionDescriptionHandlerFactoryOptions: { peerConnectionConfiguration: { iceServers: [ { urls: 'stun:stun.l.google.com:19302' }, { urls: turnServer, username: 'webrtc', credential: turnPass, } ] } }, delegate: { onInvite: handleIncomingCall, } }; userAgent = new SIP.UserAgent(userAgentOptions); const registerer = new SIP.Registerer(userAgent); userAgent.start().then(() => { registerer.register(); setStatus(`Registered as ${ext}`); document.getElementById('configPanel').style.display = 'none'; }).catch(err => { setStatus(`Registration failed: ${err.message}`, true); console.error('Registration error:', err); }); } function handleIncomingCall(invitation) { const caller = invitation.remoteIdentity.displayName || invitation.remoteIdentity.uri.user || 'Unknown'; display.textContent = `Incoming: ${caller}`; setStatus('Ringing...'); // Auto-answer (in production, show accept/reject buttons) currentSession = invitation; setupSessionHandlers(invitation); invitation.accept({ sessionDescriptionHandlerOptions: { constraints: { audio: true, video: false } } }); btnCall.disabled = true; btnHangup.disabled = false; } function makeCall() { if (!userAgent || !dialedNumber) return; const domain = userAgent.configuration.uri.host; const target = SIP.UserAgent.makeURI(`sip:${dialedNumber}@${domain}`); setStatus(`Calling ${dialedNumber}...`); const inviter = new SIP.Inviter(userAgent, target, { sessionDescriptionHandlerOptions: { constraints: { audio: true, video: false } } }); currentSession = inviter; setupSessionHandlers(inviter); inviter.invite(); btnCall.disabled = true; btnHangup.disabled = false; } function setupSessionHandlers(session) { session.stateChange.addListener((state) => { switch (state) { case SIP.SessionState.Establishing: setStatus('Connecting...'); break; case SIP.SessionState.Established: setStatus('In Call'); // Attach remote audio const remoteStream = new MediaStream(); session.sessionDescriptionHandler .peerConnection.getReceivers() .forEach(receiver => { if (receiver.track) { remoteStream.addTrack(receiver.track); } }); document.getElementById('remoteAudio').srcObject = remoteStream; break; case SIP.SessionState.Terminated: setStatus('Call Ended'); display.textContent = 'Ready'; currentSession = null; dialedNumber = ''; btnCall.disabled = false; btnHangup.disabled = true; break; } }); } function hangUp() { if (!currentSession) return; switch (currentSession.state) { case SIP.SessionState.Initial: case SIP.SessionState.Establishing: currentSession.cancel(); break; case SIP.SessionState.Established: currentSession.bye(); break; } currentSession = null; dialedNumber = ''; display.textContent = 'Ready'; setStatus('Ready'); btnCall.disabled = false; btnHangup.disabled = true; } // Clear display on double-click display.addEventListener('dblclick', () => { dialedNumber = ''; display.textContent = 'Ready'; }); </script>
</body>
</html>
<!DOCTYPE html>
<html lang="en">
<head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Web Phone</title> <script src="https://unpkg.com/[email protected]/lib/platform/web/sip.js"></script> <style> * { box-sizing: border-box; margin: 0; padding: 0; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; display: flex; justify-content: center; align-items: center; min-height: 100vh; background: #1a1a2e; } .phone { background: #16213e; border-radius: 20px; padding: 30px; width: 320px; box-shadow: 0 20px 60px rgba(0,0,0,0.5); } .display { background: #0f3460; border-radius: 10px; padding: 15px; margin-bottom: 20px; text-align: center; } .display .number { color: #e94560; font-size: 24px; font-weight: bold; min-height: 36px; word-break: break-all; } .display .status { color: #a0a0a0; font-size: 12px; margin-top: 5px; } .keypad { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin-bottom: 20px; } .key { background: #0f3460; border: none; color: white; font-size: 22px; padding: 18px; border-radius: 10px; cursor: pointer; transition: background 0.2s; } .key:hover { background: #1a5276; } .key:active { background: #e94560; } .key .sub { display: block; font-size: 10px; color: #666; } .actions { display: flex; gap: 10px; } .btn { flex: 1; padding: 15px; border: none; border-radius: 10px; font-size: 16px; font-weight: bold; cursor: pointer; } .btn-call { background: #27ae60; color: white; } .btn-call:hover { background: #2ecc71; } .btn-hangup { background: #e74c3c; color: white; } .btn-hangup:hover { background: #c0392b; } .btn:disabled { opacity: 0.3; cursor: not-allowed; } .config { margin-top: 15px; padding-top: 15px; border-top: 1px solid #0f3460; } .config input { width: 100%; padding: 8px; margin: 3px 0; background: #0f3460; border: 1px solid #1a5276; color: white; border-radius: 5px; font-size: 12px; } .config label { color: #666; font-size: 11px; } .config button { width: 100%; padding: 10px; margin-top: 8px; background: #3498db; color: white; border: none; border-radius: 5px; cursor: pointer; font-size: 13px; } audio { display: none; } </style>
</head>
<body> <div class="phone"> <div class="display"> <div class="number" id="display">Ready</div> <div class="status" id="status">Not registered</div> </div> <div class="keypad"> <button class="key" onclick="press('1')">1<span class="sub"> </span></button> <button class="key" onclick="press('2')">2<span class="sub">ABC</span></button> <button class="key" onclick="press('3')">3<span class="sub">DEF</span></button> <button class="key" onclick="press('4')">4<span class="sub">GHI</span></button> <button class="key" onclick="press('5')">5<span class="sub">JKL</span></button> <button class="key" onclick="press('6')">6<span class="sub">MNO</span></button> <button class="key" onclick="press('7')">7<span class="sub">PQRS</span></button> <button class="key" onclick="press('8')">8<span class="sub">TUV</span></button> <button class="key" onclick="press('9')">9<span class="sub">WXYZ</span></button> <button class="key" onclick="press('*')">*</button> <button class="key" onclick="press('0')">0<span class="sub">+</span></button> <button class="key" onclick="press('#')">#</button> </div> <div class="actions"> <button class="btn btn-call" id="btnCall" onclick="makeCall()">Call</button> <button class="btn btn-hangup" id="btnHangup" onclick="hangUp()" disabled>Hang Up</button> </div> <div class="config" id="configPanel"> <label>Server (WSS URL)</label> <input id="cfgServer" value="wss://YOUR_DOMAIN/ws" placeholder="wss://pbx.example.com/ws"> <label>Extension</label> <input id="cfgExt" value="1010" placeholder="1010"> <label>Password</label> <input id="cfgPass" type="password" value="" placeholder="SIP password"> <label>TURN Server</label> <input id="cfgTurn" value="turn:YOUR_DOMAIN:3478" placeholder="turn:example.com:3478"> <label>TURN Password</label> <input id="cfgTurnPass" type="password" value="" placeholder="TURN shared secret"> <button onclick="register()">Register</button> </div> <audio id="remoteAudio" autoplay></audio> <audio id="localAudio" autoplay muted></audio> </div> <script> let userAgent = null; let currentSession = null; let dialedNumber = ''; const display = document.getElementById('display'); const status = document.getElementById('status'); const btnCall = document.getElementById('btnCall'); const btnHangup = document.getElementById('btnHangup'); function press(digit) { dialedNumber += digit; display.textContent = dialedNumber; // Send DTMF if in-call if (currentSession) { currentSession.sessionDescriptionHandler .sendDtmf(digit); } } function setStatus(text, isError = false) { status.textContent = text; status.style.color = isError ? '#e74c3c' : '#a0a0a0'; } function register() { const server = document.getElementById('cfgServer').value; const ext = document.getElementById('cfgExt').value; const pass = document.getElementById('cfgPass').value; const turnServer = document.getElementById('cfgTurn').value; const turnPass = document.getElementById('cfgTurnPass').value; // Extract domain from WSS URL const domain = new URL(server).hostname; setStatus('Registering...'); const transportOptions = { server: server, keepAliveInterval: 30, }; const uri = SIP.UserAgent.makeURI(`sip:${ext}@${domain}`); const userAgentOptions = { authorizationPassword: pass, authorizationUsername: ext, transportOptions: transportOptions, uri: uri, sessionDescriptionHandlerFactoryOptions: { peerConnectionConfiguration: { iceServers: [ { urls: 'stun:stun.l.google.com:19302' }, { urls: turnServer, username: 'webrtc', credential: turnPass, } ] } }, delegate: { onInvite: handleIncomingCall, } }; userAgent = new SIP.UserAgent(userAgentOptions); const registerer = new SIP.Registerer(userAgent); userAgent.start().then(() => { registerer.register(); setStatus(`Registered as ${ext}`); document.getElementById('configPanel').style.display = 'none'; }).catch(err => { setStatus(`Registration failed: ${err.message}`, true); console.error('Registration error:', err); }); } function handleIncomingCall(invitation) { const caller = invitation.remoteIdentity.displayName || invitation.remoteIdentity.uri.user || 'Unknown'; display.textContent = `Incoming: ${caller}`; setStatus('Ringing...'); // Auto-answer (in production, show accept/reject buttons) currentSession = invitation; setupSessionHandlers(invitation); invitation.accept({ sessionDescriptionHandlerOptions: { constraints: { audio: true, video: false } } }); btnCall.disabled = true; btnHangup.disabled = false; } function makeCall() { if (!userAgent || !dialedNumber) return; const domain = userAgent.configuration.uri.host; const target = SIP.UserAgent.makeURI(`sip:${dialedNumber}@${domain}`); setStatus(`Calling ${dialedNumber}...`); const inviter = new SIP.Inviter(userAgent, target, { sessionDescriptionHandlerOptions: { constraints: { audio: true, video: false } } }); currentSession = inviter; setupSessionHandlers(inviter); inviter.invite(); btnCall.disabled = true; btnHangup.disabled = false; } function setupSessionHandlers(session) { session.stateChange.addListener((state) => { switch (state) { case SIP.SessionState.Establishing: setStatus('Connecting...'); break; case SIP.SessionState.Established: setStatus('In Call'); // Attach remote audio const remoteStream = new MediaStream(); session.sessionDescriptionHandler .peerConnection.getReceivers() .forEach(receiver => { if (receiver.track) { remoteStream.addTrack(receiver.track); } }); document.getElementById('remoteAudio').srcObject = remoteStream; break; case SIP.SessionState.Terminated: setStatus('Call Ended'); display.textContent = 'Ready'; currentSession = null; dialedNumber = ''; btnCall.disabled = false; btnHangup.disabled = true; break; } }); } function hangUp() { if (!currentSession) return; switch (currentSession.state) { case SIP.SessionState.Initial: case SIP.SessionState.Establishing: currentSession.cancel(); break; case SIP.SessionState.Established: currentSession.bye(); break; } currentSession = null; dialedNumber = ''; display.textContent = 'Ready'; setStatus('Ready'); btnCall.disabled = false; btnHangup.disabled = true; } // Clear display on double-click display.addEventListener('dblclick', () => { dialedNumber = ''; display.textContent = 'Ready'; }); </script>
</body>
</html>
# 1. Verify WebSocket transport is loaded
docker compose exec asterisk asterisk -rx "pjsip show transports" | grep wss # 2. Verify DTLS is working
docker compose exec asterisk asterisk -rx "module show like dtls" # 3. Verify Coturn is running
docker compose logs coturn | tail -5 # 4. Test TURN server connectivity
# From any machine with turnutils installed:
turnutils_uclient -T -u webrtc -w YOUR_TURN_SECRET YOUR_DOMAIN # 5. Open https://YOUR_DOMAIN in a browser
# Register with extension 1010 and the SIP password
# Call extension 1001 to test
# 1. Verify WebSocket transport is loaded
docker compose exec asterisk asterisk -rx "pjsip show transports" | grep wss # 2. Verify DTLS is working
docker compose exec asterisk asterisk -rx "module show like dtls" # 3. Verify Coturn is running
docker compose logs coturn | tail -5 # 4. Test TURN server connectivity
# From any machine with turnutils installed:
turnutils_uclient -T -u webrtc -w YOUR_TURN_SECRET YOUR_DOMAIN # 5. Open https://YOUR_DOMAIN in a browser
# Register with extension 1010 and the SIP password
# Call extension 1001 to test
# 1. Verify WebSocket transport is loaded
docker compose exec asterisk asterisk -rx "pjsip show transports" | grep wss # 2. Verify DTLS is working
docker compose exec asterisk asterisk -rx "module show like dtls" # 3. Verify Coturn is running
docker compose logs coturn | tail -5 # 4. Test TURN server connectivity
# From any machine with turnutils installed:
turnutils_uclient -T -u webrtc -w YOUR_TURN_SECRET YOUR_DOMAIN # 5. Open https://YOUR_DOMAIN in a browser
# Register with extension 1010 and the SIP password
# Call extension 1001 to test
# Check health status of all containers
docker compose ps
# NAME STATUS PORTS
# asterisk Up (healthy) ...
# asterisk-mariadb Up (healthy) ...
# asterisk-redis Up (healthy) ... # Detailed health check output
docker inspect --format='{{json .State.Health}}' asterisk | jq .
# Check health status of all containers
docker compose ps
# NAME STATUS PORTS
# asterisk Up (healthy) ...
# asterisk-mariadb Up (healthy) ...
# asterisk-redis Up (healthy) ... # Detailed health check output
docker inspect --format='{{json .State.Health}}' asterisk | jq .
# Check health status of all containers
docker compose ps
# NAME STATUS PORTS
# asterisk Up (healthy) ...
# asterisk-mariadb Up (healthy) ...
# asterisk-redis Up (healthy) ... # Detailed health check output
docker inspect --format='{{json .State.Health}}' asterisk | jq .
#!/usr/bin/env python3
"""
Asterisk Prometheus Exporter (sidecar)
Exposes Asterisk metrics at :9200/metrics Connects to Asterisk via AMI (local socket or TCP).
""" import http.server
import os
import re
import socket
import time AMI_HOST = os.getenv("AMI_HOST", "127.0.0.1")
AMI_PORT = int(os.getenv("AMI_PORT", "5038"))
AMI_USER = os.getenv("AMI_USERNAME", "ami_admin")
AMI_PASS = os.getenv("AMI_PASSWORD", "")
LISTEN_PORT = int(os.getenv("EXPORTER_PORT", "9200")) def ami_command(action: str, **kwargs) -> str: """Send an AMI command and return the response.""" try: sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.settimeout(5) sock.connect((AMI_HOST, AMI_PORT)) # Read banner sock.recv(4096) # Login login = f"Action: Login\r\nUsername: {AMI_USER}\r\nSecret: {AMI_PASS}\r\n\r\n" sock.send(login.encode()) sock.recv(4096) # Send command cmd = f"Action: {action}\r\n" for k, v in kwargs.items(): cmd += f"{k}: {v}\r\n" cmd += "\r\n" sock.send(cmd.encode()) # Read response (with timeout) response = b"" while True: try: data = sock.recv(4096) if not data: break response += data if b"\r\n\r\n" in data: break except socket.timeout: break # Logoff sock.send(b"Action: Logoff\r\n\r\n") sock.close() return response.decode("utf-8", errors="replace") except Exception as e: return f"Error: {e}" def collect_metrics() -> str: """Collect all metrics and return Prometheus text format.""" lines = [] # ---- Active channels ---- resp = ami_command("Command", Command="core show channels count") match = re.search(r"(\d+) active channel", resp) channels = int(match.group(1)) if match else 0 match2 = re.search(r"(\d+) active call", resp) calls = int(match2.group(1)) if match2 else 0 lines.append("# HELP asterisk_active_channels Number of active channels") lines.append("# TYPE asterisk_active_channels gauge") lines.append(f"asterisk_active_channels {channels}") lines.append("# HELP asterisk_active_calls Number of active calls") lines.append("# TYPE asterisk_active_calls gauge") lines.append(f"asterisk_active_calls {calls}") # ---- PJSIP endpoints ---- resp = ami_command("Command", Command="pjsip show endpoints") available = len(re.findall(r"Avail\b", resp)) unavailable = len(re.findall(r"Unavail\b", resp)) lines.append("# HELP asterisk_pjsip_endpoints_available Available PJSIP endpoints") lines.append("# TYPE asterisk_pjsip_endpoints_available gauge") lines.append(f"asterisk_pjsip_endpoints_available {available}") lines.append("# HELP asterisk_pjsip_endpoints_unavailable Unavailable PJSIP endpoints") lines.append("# TYPE asterisk_pjsip_endpoints_unavailable gauge") lines.append(f"asterisk_pjsip_endpoints_unavailable {unavailable}") # ---- Uptime ---- resp = ami_command("Command", Command="core show uptime seconds") match = re.search(r"System uptime:\s+(\d+)", resp) uptime = int(match.group(1)) if match else 0 match2 = re.search(r"Last reload:\s+(\d+)", resp) reload_time = int(match2.group(1)) if match2 else 0 lines.append("# HELP asterisk_uptime_seconds System uptime in seconds") lines.append("# TYPE asterisk_uptime_seconds gauge") lines.append(f"asterisk_uptime_seconds {uptime}") # ---- SIP registrations ---- resp = ami_command("Command", Command="pjsip show registrations") registered = len(re.findall(r"Registered\b", resp)) unregistered = len(re.findall(r"Unregistered\b", resp)) lines.append("# HELP asterisk_pjsip_registrations_registered Registered trunk count") lines.append("# TYPE asterisk_pjsip_registrations_registered gauge") lines.append(f"asterisk_pjsip_registrations_registered {registered}") lines.append("# HELP asterisk_pjsip_registrations_unregistered Unregistered trunk count") lines.append("# TYPE asterisk_pjsip_registrations_unregistered gauge") lines.append(f"asterisk_pjsip_registrations_unregistered {unregistered}") # ---- ConfBridge conferences ---- resp = ami_command("Command", Command="confbridge list") conferences = len(re.findall(r"Conference\s+\d+", resp)) lines.append("# HELP asterisk_confbridge_active Active conference rooms") lines.append("# TYPE asterisk_confbridge_active gauge") lines.append(f"asterisk_confbridge_active {conferences}") return "\n".join(lines) + "\n" class MetricsHandler(http.server.BaseHTTPRequestHandler): """HTTP handler for /metrics endpoint.""" def do_GET(self): if self.path == "/metrics": metrics = collect_metrics() self.send_response(200) self.send_header("Content-Type", "text/plain; version=0.0.4") self.end_headers() self.wfile.write(metrics.encode()) elif self.path == "/health": self.send_response(200) self.end_headers() self.wfile.write(b"OK\n") else: self.send_response(404) self.end_headers() def log_message(self, format, *args): pass # Suppress access logs if __name__ == "__main__": print(f"Asterisk Prometheus Exporter listening on :{LISTEN_PORT}") server = http.server.HTTPServer(("0.0.0.0", LISTEN_PORT), MetricsHandler) server.serve_forever()
#!/usr/bin/env python3
"""
Asterisk Prometheus Exporter (sidecar)
Exposes Asterisk metrics at :9200/metrics Connects to Asterisk via AMI (local socket or TCP).
""" import http.server
import os
import re
import socket
import time AMI_HOST = os.getenv("AMI_HOST", "127.0.0.1")
AMI_PORT = int(os.getenv("AMI_PORT", "5038"))
AMI_USER = os.getenv("AMI_USERNAME", "ami_admin")
AMI_PASS = os.getenv("AMI_PASSWORD", "")
LISTEN_PORT = int(os.getenv("EXPORTER_PORT", "9200")) def ami_command(action: str, **kwargs) -> str: """Send an AMI command and return the response.""" try: sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.settimeout(5) sock.connect((AMI_HOST, AMI_PORT)) # Read banner sock.recv(4096) # Login login = f"Action: Login\r\nUsername: {AMI_USER}\r\nSecret: {AMI_PASS}\r\n\r\n" sock.send(login.encode()) sock.recv(4096) # Send command cmd = f"Action: {action}\r\n" for k, v in kwargs.items(): cmd += f"{k}: {v}\r\n" cmd += "\r\n" sock.send(cmd.encode()) # Read response (with timeout) response = b"" while True: try: data = sock.recv(4096) if not data: break response += data if b"\r\n\r\n" in data: break except socket.timeout: break # Logoff sock.send(b"Action: Logoff\r\n\r\n") sock.close() return response.decode("utf-8", errors="replace") except Exception as e: return f"Error: {e}" def collect_metrics() -> str: """Collect all metrics and return Prometheus text format.""" lines = [] # ---- Active channels ---- resp = ami_command("Command", Command="core show channels count") match = re.search(r"(\d+) active channel", resp) channels = int(match.group(1)) if match else 0 match2 = re.search(r"(\d+) active call", resp) calls = int(match2.group(1)) if match2 else 0 lines.append("# HELP asterisk_active_channels Number of active channels") lines.append("# TYPE asterisk_active_channels gauge") lines.append(f"asterisk_active_channels {channels}") lines.append("# HELP asterisk_active_calls Number of active calls") lines.append("# TYPE asterisk_active_calls gauge") lines.append(f"asterisk_active_calls {calls}") # ---- PJSIP endpoints ---- resp = ami_command("Command", Command="pjsip show endpoints") available = len(re.findall(r"Avail\b", resp)) unavailable = len(re.findall(r"Unavail\b", resp)) lines.append("# HELP asterisk_pjsip_endpoints_available Available PJSIP endpoints") lines.append("# TYPE asterisk_pjsip_endpoints_available gauge") lines.append(f"asterisk_pjsip_endpoints_available {available}") lines.append("# HELP asterisk_pjsip_endpoints_unavailable Unavailable PJSIP endpoints") lines.append("# TYPE asterisk_pjsip_endpoints_unavailable gauge") lines.append(f"asterisk_pjsip_endpoints_unavailable {unavailable}") # ---- Uptime ---- resp = ami_command("Command", Command="core show uptime seconds") match = re.search(r"System uptime:\s+(\d+)", resp) uptime = int(match.group(1)) if match else 0 match2 = re.search(r"Last reload:\s+(\d+)", resp) reload_time = int(match2.group(1)) if match2 else 0 lines.append("# HELP asterisk_uptime_seconds System uptime in seconds") lines.append("# TYPE asterisk_uptime_seconds gauge") lines.append(f"asterisk_uptime_seconds {uptime}") # ---- SIP registrations ---- resp = ami_command("Command", Command="pjsip show registrations") registered = len(re.findall(r"Registered\b", resp)) unregistered = len(re.findall(r"Unregistered\b", resp)) lines.append("# HELP asterisk_pjsip_registrations_registered Registered trunk count") lines.append("# TYPE asterisk_pjsip_registrations_registered gauge") lines.append(f"asterisk_pjsip_registrations_registered {registered}") lines.append("# HELP asterisk_pjsip_registrations_unregistered Unregistered trunk count") lines.append("# TYPE asterisk_pjsip_registrations_unregistered gauge") lines.append(f"asterisk_pjsip_registrations_unregistered {unregistered}") # ---- ConfBridge conferences ---- resp = ami_command("Command", Command="confbridge list") conferences = len(re.findall(r"Conference\s+\d+", resp)) lines.append("# HELP asterisk_confbridge_active Active conference rooms") lines.append("# TYPE asterisk_confbridge_active gauge") lines.append(f"asterisk_confbridge_active {conferences}") return "\n".join(lines) + "\n" class MetricsHandler(http.server.BaseHTTPRequestHandler): """HTTP handler for /metrics endpoint.""" def do_GET(self): if self.path == "/metrics": metrics = collect_metrics() self.send_response(200) self.send_header("Content-Type", "text/plain; version=0.0.4") self.end_headers() self.wfile.write(metrics.encode()) elif self.path == "/health": self.send_response(200) self.end_headers() self.wfile.write(b"OK\n") else: self.send_response(404) self.end_headers() def log_message(self, format, *args): pass # Suppress access logs if __name__ == "__main__": print(f"Asterisk Prometheus Exporter listening on :{LISTEN_PORT}") server = http.server.HTTPServer(("0.0.0.0", LISTEN_PORT), MetricsHandler) server.serve_forever()
#!/usr/bin/env python3
"""
Asterisk Prometheus Exporter (sidecar)
Exposes Asterisk metrics at :9200/metrics Connects to Asterisk via AMI (local socket or TCP).
""" import http.server
import os
import re
import socket
import time AMI_HOST = os.getenv("AMI_HOST", "127.0.0.1")
AMI_PORT = int(os.getenv("AMI_PORT", "5038"))
AMI_USER = os.getenv("AMI_USERNAME", "ami_admin")
AMI_PASS = os.getenv("AMI_PASSWORD", "")
LISTEN_PORT = int(os.getenv("EXPORTER_PORT", "9200")) def ami_command(action: str, **kwargs) -> str: """Send an AMI command and return the response.""" try: sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.settimeout(5) sock.connect((AMI_HOST, AMI_PORT)) # Read banner sock.recv(4096) # Login login = f"Action: Login\r\nUsername: {AMI_USER}\r\nSecret: {AMI_PASS}\r\n\r\n" sock.send(login.encode()) sock.recv(4096) # Send command cmd = f"Action: {action}\r\n" for k, v in kwargs.items(): cmd += f"{k}: {v}\r\n" cmd += "\r\n" sock.send(cmd.encode()) # Read response (with timeout) response = b"" while True: try: data = sock.recv(4096) if not data: break response += data if b"\r\n\r\n" in data: break except socket.timeout: break # Logoff sock.send(b"Action: Logoff\r\n\r\n") sock.close() return response.decode("utf-8", errors="replace") except Exception as e: return f"Error: {e}" def collect_metrics() -> str: """Collect all metrics and return Prometheus text format.""" lines = [] # ---- Active channels ---- resp = ami_command("Command", Command="core show channels count") match = re.search(r"(\d+) active channel", resp) channels = int(match.group(1)) if match else 0 match2 = re.search(r"(\d+) active call", resp) calls = int(match2.group(1)) if match2 else 0 lines.append("# HELP asterisk_active_channels Number of active channels") lines.append("# TYPE asterisk_active_channels gauge") lines.append(f"asterisk_active_channels {channels}") lines.append("# HELP asterisk_active_calls Number of active calls") lines.append("# TYPE asterisk_active_calls gauge") lines.append(f"asterisk_active_calls {calls}") # ---- PJSIP endpoints ---- resp = ami_command("Command", Command="pjsip show endpoints") available = len(re.findall(r"Avail\b", resp)) unavailable = len(re.findall(r"Unavail\b", resp)) lines.append("# HELP asterisk_pjsip_endpoints_available Available PJSIP endpoints") lines.append("# TYPE asterisk_pjsip_endpoints_available gauge") lines.append(f"asterisk_pjsip_endpoints_available {available}") lines.append("# HELP asterisk_pjsip_endpoints_unavailable Unavailable PJSIP endpoints") lines.append("# TYPE asterisk_pjsip_endpoints_unavailable gauge") lines.append(f"asterisk_pjsip_endpoints_unavailable {unavailable}") # ---- Uptime ---- resp = ami_command("Command", Command="core show uptime seconds") match = re.search(r"System uptime:\s+(\d+)", resp) uptime = int(match.group(1)) if match else 0 match2 = re.search(r"Last reload:\s+(\d+)", resp) reload_time = int(match2.group(1)) if match2 else 0 lines.append("# HELP asterisk_uptime_seconds System uptime in seconds") lines.append("# TYPE asterisk_uptime_seconds gauge") lines.append(f"asterisk_uptime_seconds {uptime}") # ---- SIP registrations ---- resp = ami_command("Command", Command="pjsip show registrations") registered = len(re.findall(r"Registered\b", resp)) unregistered = len(re.findall(r"Unregistered\b", resp)) lines.append("# HELP asterisk_pjsip_registrations_registered Registered trunk count") lines.append("# TYPE asterisk_pjsip_registrations_registered gauge") lines.append(f"asterisk_pjsip_registrations_registered {registered}") lines.append("# HELP asterisk_pjsip_registrations_unregistered Unregistered trunk count") lines.append("# TYPE asterisk_pjsip_registrations_unregistered gauge") lines.append(f"asterisk_pjsip_registrations_unregistered {unregistered}") # ---- ConfBridge conferences ---- resp = ami_command("Command", Command="confbridge list") conferences = len(re.findall(r"Conference\s+\d+", resp)) lines.append("# HELP asterisk_confbridge_active Active conference rooms") lines.append("# TYPE asterisk_confbridge_active gauge") lines.append(f"asterisk_confbridge_active {conferences}") return "\n".join(lines) + "\n" class MetricsHandler(http.server.BaseHTTPRequestHandler): """HTTP handler for /metrics endpoint.""" def do_GET(self): if self.path == "/metrics": metrics = collect_metrics() self.send_response(200) self.send_header("Content-Type", "text/plain; version=0.0.4") self.end_headers() self.wfile.write(metrics.encode()) elif self.path == "/health": self.send_response(200) self.end_headers() self.wfile.write(b"OK\n") else: self.send_response(404) self.end_headers() def log_message(self, format, *args): pass # Suppress access logs if __name__ == "__main__": print(f"Asterisk Prometheus Exporter listening on :{LISTEN_PORT}") server = http.server.HTTPServer(("0.0.0.0", LISTEN_PORT), MetricsHandler) server.serve_forever()
; =============================================================================
; AMI (Asterisk Manager Interface) Configuration
; Used by Prometheus exporter and management tools
; ============================================================================= [general]
enabled = yes
port = 5038
bindaddr = 0.0.0.0 [${AMI_USERNAME}]
secret = ${AMI_PASSWORD}
deny = 0.0.0.0/0.0.0.0
permit = 127.0.0.1/255.255.255.0
permit = 172.25.0.0/255.255.255.0
read = system,call,log,command,agent,user,config,dtmf,reporting,cdr,dialplan
write = system,call,command,originate,reporting
writetimeout = 5000
; =============================================================================
; AMI (Asterisk Manager Interface) Configuration
; Used by Prometheus exporter and management tools
; ============================================================================= [general]
enabled = yes
port = 5038
bindaddr = 0.0.0.0 [${AMI_USERNAME}]
secret = ${AMI_PASSWORD}
deny = 0.0.0.0/0.0.0.0
permit = 127.0.0.1/255.255.255.0
permit = 172.25.0.0/255.255.255.0
read = system,call,log,command,agent,user,config,dtmf,reporting,cdr,dialplan
write = system,call,command,originate,reporting
writetimeout = 5000
; =============================================================================
; AMI (Asterisk Manager Interface) Configuration
; Used by Prometheus exporter and management tools
; ============================================================================= [general]
enabled = yes
port = 5038
bindaddr = 0.0.0.0 [${AMI_USERNAME}]
secret = ${AMI_PASSWORD}
deny = 0.0.0.0/0.0.0.0
permit = 127.0.0.1/255.255.255.0
permit = 172.25.0.0/255.255.255.0
read = system,call,log,command,agent,user,config,dtmf,reporting,cdr,dialplan
write = system,call,command,originate,reporting
writetimeout = 5000
# --------------------------------------------------------------------------- # Promtail - Log Shipping to Loki # --------------------------------------------------------------------------- promtail: image: grafana/promtail:latest container_name: asterisk-promtail restart: unless-stopped networks: - asterisk-net volumes: - asterisk-logs:/var/log/asterisk:ro - ./promtail/config.yml:/etc/promtail/config.yml:ro command: -config.file=/etc/promtail/config.yml deploy: resources: limits: cpus: '0.25' memory: 128M
# --------------------------------------------------------------------------- # Promtail - Log Shipping to Loki # --------------------------------------------------------------------------- promtail: image: grafana/promtail:latest container_name: asterisk-promtail restart: unless-stopped networks: - asterisk-net volumes: - asterisk-logs:/var/log/asterisk:ro - ./promtail/config.yml:/etc/promtail/config.yml:ro command: -config.file=/etc/promtail/config.yml deploy: resources: limits: cpus: '0.25' memory: 128M
# --------------------------------------------------------------------------- # Promtail - Log Shipping to Loki # --------------------------------------------------------------------------- promtail: image: grafana/promtail:latest container_name: asterisk-promtail restart: unless-stopped networks: - asterisk-net volumes: - asterisk-logs:/var/log/asterisk:ro - ./promtail/config.yml:/etc/promtail/config.yml:ro command: -config.file=/etc/promtail/config.yml deploy: resources: limits: cpus: '0.25' memory: 128M
# =============================================================================
# Promtail Configuration - Ship Asterisk Logs to Loki
# ============================================================================= server: http_listen_port: 9080 grpc_listen_port: 0 positions: filename: /tmp/positions.yaml clients: # Point to your Loki instance - url: http://YOUR_LOKI_HOST:3100/loki/api/v1/push scrape_configs: # Asterisk messages log - job_name: asterisk-messages static_configs: - targets: - localhost labels: job: asterisk host: asterisk-docker __path__: /var/log/asterisk/messages pipeline_stages: - regex: expression: '^\[(?P<timestamp>[^\]]+)\]\s+(?P<level>\w+)\[(?P<thread>\d+)\]\s+(?P<module>[^:]+):\s+(?P<message>.*)$' - labels: level: module: - timestamp: source: timestamp format: "2006-01-02 15:04:05.000" # Asterisk security log - job_name: asterisk-security static_configs: - targets: - localhost labels: job: asterisk-security host: asterisk-docker __path__: /var/log/asterisk/security
# =============================================================================
# Promtail Configuration - Ship Asterisk Logs to Loki
# ============================================================================= server: http_listen_port: 9080 grpc_listen_port: 0 positions: filename: /tmp/positions.yaml clients: # Point to your Loki instance - url: http://YOUR_LOKI_HOST:3100/loki/api/v1/push scrape_configs: # Asterisk messages log - job_name: asterisk-messages static_configs: - targets: - localhost labels: job: asterisk host: asterisk-docker __path__: /var/log/asterisk/messages pipeline_stages: - regex: expression: '^\[(?P<timestamp>[^\]]+)\]\s+(?P<level>\w+)\[(?P<thread>\d+)\]\s+(?P<module>[^:]+):\s+(?P<message>.*)$' - labels: level: module: - timestamp: source: timestamp format: "2006-01-02 15:04:05.000" # Asterisk security log - job_name: asterisk-security static_configs: - targets: - localhost labels: job: asterisk-security host: asterisk-docker __path__: /var/log/asterisk/security
# =============================================================================
# Promtail Configuration - Ship Asterisk Logs to Loki
# ============================================================================= server: http_listen_port: 9080 grpc_listen_port: 0 positions: filename: /tmp/positions.yaml clients: # Point to your Loki instance - url: http://YOUR_LOKI_HOST:3100/loki/api/v1/push scrape_configs: # Asterisk messages log - job_name: asterisk-messages static_configs: - targets: - localhost labels: job: asterisk host: asterisk-docker __path__: /var/log/asterisk/messages pipeline_stages: - regex: expression: '^\[(?P<timestamp>[^\]]+)\]\s+(?P<level>\w+)\[(?P<thread>\d+)\]\s+(?P<module>[^:]+):\s+(?P<message>.*)$' - labels: level: module: - timestamp: source: timestamp format: "2006-01-02 15:04:05.000" # Asterisk security log - job_name: asterisk-security static_configs: - targets: - localhost labels: job: asterisk-security host: asterisk-docker __path__: /var/log/asterisk/security
┌──────────────┐ │ Kamailio │ SIP Load Balancer / Proxy │ (SIP Proxy) │ - Distributes calls by hash or weight └──────┬───────┘ - Health checks Asterisk instances │ - Handles registration (single point) ┌────────────┼────────────┐ │ │ │ ┌────────▼───┐ ┌──────▼─────┐ ┌───▼────────┐ │ Asterisk 1 │ │ Asterisk 2 │ │ Asterisk 3 │ │ (Container)│ │ (Container)│ │ (Container)│ └─────┬──────┘ └─────┬──────┘ └─────┬──────┘ │ │ │ ┌─────▼───────────────▼───────────────▼─────┐ │ Shared Services │ │ MariaDB (Primary + Replica) │ │ Redis Cluster │ │ NFS/S3 (Recordings) │ └────────────────────────────────────────────┘
┌──────────────┐ │ Kamailio │ SIP Load Balancer / Proxy │ (SIP Proxy) │ - Distributes calls by hash or weight └──────┬───────┘ - Health checks Asterisk instances │ - Handles registration (single point) ┌────────────┼────────────┐ │ │ │ ┌────────▼───┐ ┌──────▼─────┐ ┌───▼────────┐ │ Asterisk 1 │ │ Asterisk 2 │ │ Asterisk 3 │ │ (Container)│ │ (Container)│ │ (Container)│ └─────┬──────┘ └─────┬──────┘ └─────┬──────┘ │ │ │ ┌─────▼───────────────▼───────────────▼─────┐ │ Shared Services │ │ MariaDB (Primary + Replica) │ │ Redis Cluster │ │ NFS/S3 (Recordings) │ └────────────────────────────────────────────┘
┌──────────────┐ │ Kamailio │ SIP Load Balancer / Proxy │ (SIP Proxy) │ - Distributes calls by hash or weight └──────┬───────┘ - Health checks Asterisk instances │ - Handles registration (single point) ┌────────────┼────────────┐ │ │ │ ┌────────▼───┐ ┌──────▼─────┐ ┌───▼────────┐ │ Asterisk 1 │ │ Asterisk 2 │ │ Asterisk 3 │ │ (Container)│ │ (Container)│ │ (Container)│ └─────┬──────┘ └─────┬──────┘ └─────┬──────┘ │ │ │ ┌─────▼───────────────▼───────────────▼─────┐ │ Shared Services │ │ MariaDB (Primary + Replica) │ │ Redis Cluster │ │ NFS/S3 (Recordings) │ └────────────────────────────────────────────┘
# docker-compose.scale.yml - Multi-instance Asterisk
# Usage: docker compose -f docker-compose.scale.yml up -d --scale asterisk=3 services: asterisk: build: ./asterisk # NO ports mapping - Kamailio handles external traffic networks: - asterisk-net environment: - PJSIP_EXTERNAL_IP=${PJSIP_EXTERNAL_IP} - DB_HOST=mariadb-primary # Each instance gets unique name via Docker volumes: - ./asterisk/configs:/etc/asterisk/templates:ro - recordings-nfs:/var/spool/asterisk/monitor depends_on: - mariadb-primary - redis deploy: replicas: 3 resources: limits: cpus: '2' memory: 2G kamailio: image: kamailio/kamailio:latest ports: - "5060:5060/udp" - "5060:5060/tcp" - "5061:5061/tcp" volumes: - ./kamailio/kamailio.cfg:/etc/kamailio/kamailio.cfg:ro networks: - asterisk-net mariadb-primary: image: mariadb:11.4 environment: - MARIADB_ROOT_PASSWORD=${DB_ROOT_PASSWORD} - MARIADB_DATABASE=${DB_NAME} - MARIADB_USER=${DB_USER} - MARIADB_PASSWORD=${DB_PASSWORD} - MARIADB_REPLICATION_MODE=master volumes: - mariadb-primary-data:/var/lib/mysql networks: - asterisk-net mariadb-replica: image: mariadb:11.4 environment: - MARIADB_REPLICATION_MODE=slave - MARIADB_MASTER_HOST=mariadb-primary - MARIADB_MASTER_ROOT_PASSWORD=${DB_ROOT_PASSWORD} volumes: - mariadb-replica-data:/var/lib/mysql depends_on: - mariadb-primary networks: - asterisk-net redis: image: redis:7-alpine command: redis-server --requirepass ${REDIS_PASSWORD} --appendonly yes volumes: - redis-data:/data networks: - asterisk-net volumes: mariadb-primary-data: mariadb-replica-data: redis-data: recordings-nfs: driver: local driver_opts: type: nfs o: "addr=NFS_SERVER_IP,nolock,soft,rw" device: ":/exports/asterisk-recordings" networks: asterisk-net: driver: bridge
# docker-compose.scale.yml - Multi-instance Asterisk
# Usage: docker compose -f docker-compose.scale.yml up -d --scale asterisk=3 services: asterisk: build: ./asterisk # NO ports mapping - Kamailio handles external traffic networks: - asterisk-net environment: - PJSIP_EXTERNAL_IP=${PJSIP_EXTERNAL_IP} - DB_HOST=mariadb-primary # Each instance gets unique name via Docker volumes: - ./asterisk/configs:/etc/asterisk/templates:ro - recordings-nfs:/var/spool/asterisk/monitor depends_on: - mariadb-primary - redis deploy: replicas: 3 resources: limits: cpus: '2' memory: 2G kamailio: image: kamailio/kamailio:latest ports: - "5060:5060/udp" - "5060:5060/tcp" - "5061:5061/tcp" volumes: - ./kamailio/kamailio.cfg:/etc/kamailio/kamailio.cfg:ro networks: - asterisk-net mariadb-primary: image: mariadb:11.4 environment: - MARIADB_ROOT_PASSWORD=${DB_ROOT_PASSWORD} - MARIADB_DATABASE=${DB_NAME} - MARIADB_USER=${DB_USER} - MARIADB_PASSWORD=${DB_PASSWORD} - MARIADB_REPLICATION_MODE=master volumes: - mariadb-primary-data:/var/lib/mysql networks: - asterisk-net mariadb-replica: image: mariadb:11.4 environment: - MARIADB_REPLICATION_MODE=slave - MARIADB_MASTER_HOST=mariadb-primary - MARIADB_MASTER_ROOT_PASSWORD=${DB_ROOT_PASSWORD} volumes: - mariadb-replica-data:/var/lib/mysql depends_on: - mariadb-primary networks: - asterisk-net redis: image: redis:7-alpine command: redis-server --requirepass ${REDIS_PASSWORD} --appendonly yes volumes: - redis-data:/data networks: - asterisk-net volumes: mariadb-primary-data: mariadb-replica-data: redis-data: recordings-nfs: driver: local driver_opts: type: nfs o: "addr=NFS_SERVER_IP,nolock,soft,rw" device: ":/exports/asterisk-recordings" networks: asterisk-net: driver: bridge
# docker-compose.scale.yml - Multi-instance Asterisk
# Usage: docker compose -f docker-compose.scale.yml up -d --scale asterisk=3 services: asterisk: build: ./asterisk # NO ports mapping - Kamailio handles external traffic networks: - asterisk-net environment: - PJSIP_EXTERNAL_IP=${PJSIP_EXTERNAL_IP} - DB_HOST=mariadb-primary # Each instance gets unique name via Docker volumes: - ./asterisk/configs:/etc/asterisk/templates:ro - recordings-nfs:/var/spool/asterisk/monitor depends_on: - mariadb-primary - redis deploy: replicas: 3 resources: limits: cpus: '2' memory: 2G kamailio: image: kamailio/kamailio:latest ports: - "5060:5060/udp" - "5060:5060/tcp" - "5061:5061/tcp" volumes: - ./kamailio/kamailio.cfg:/etc/kamailio/kamailio.cfg:ro networks: - asterisk-net mariadb-primary: image: mariadb:11.4 environment: - MARIADB_ROOT_PASSWORD=${DB_ROOT_PASSWORD} - MARIADB_DATABASE=${DB_NAME} - MARIADB_USER=${DB_USER} - MARIADB_PASSWORD=${DB_PASSWORD} - MARIADB_REPLICATION_MODE=master volumes: - mariadb-primary-data:/var/lib/mysql networks: - asterisk-net mariadb-replica: image: mariadb:11.4 environment: - MARIADB_REPLICATION_MODE=slave - MARIADB_MASTER_HOST=mariadb-primary - MARIADB_MASTER_ROOT_PASSWORD=${DB_ROOT_PASSWORD} volumes: - mariadb-replica-data:/var/lib/mysql depends_on: - mariadb-primary networks: - asterisk-net redis: image: redis:7-alpine command: redis-server --requirepass ${REDIS_PASSWORD} --appendonly yes volumes: - redis-data:/data networks: - asterisk-net volumes: mariadb-primary-data: mariadb-replica-data: redis-data: recordings-nfs: driver: local driver_opts: type: nfs o: "addr=NFS_SERVER_IP,nolock,soft,rw" device: ":/exports/asterisk-recordings" networks: asterisk-net: driver: bridge
# Example: Track active calls across all instances
import redis
import json r = redis.Redis(host='redis', port=6379, password='...') # When a call starts on any instance
def on_call_start(call_id, caller, callee, instance_id): r.hset(f"active_call:{call_id}", mapping={ "caller": caller, "callee": callee, "instance": instance_id, "start_time": str(time.time()), }) r.expire(f"active_call:{call_id}", 7200) # 2-hour TTL # Query active calls across all instances
def get_active_calls(): calls = [] for key in r.scan_iter("active_call:*"): call = r.hgetall(key) calls.append(call) return calls
# Example: Track active calls across all instances
import redis
import json r = redis.Redis(host='redis', port=6379, password='...') # When a call starts on any instance
def on_call_start(call_id, caller, callee, instance_id): r.hset(f"active_call:{call_id}", mapping={ "caller": caller, "callee": callee, "instance": instance_id, "start_time": str(time.time()), }) r.expire(f"active_call:{call_id}", 7200) # 2-hour TTL # Query active calls across all instances
def get_active_calls(): calls = [] for key in r.scan_iter("active_call:*"): call = r.hgetall(key) calls.append(call) return calls
# Example: Track active calls across all instances
import redis
import json r = redis.Redis(host='redis', port=6379, password='...') # When a call starts on any instance
def on_call_start(call_id, caller, callee, instance_id): r.hset(f"active_call:{call_id}", mapping={ "caller": caller, "callee": callee, "instance": instance_id, "start_time": str(time.time()), }) r.expire(f"active_call:{call_id}", 7200) # 2-hour TTL # Query active calls across all instances
def get_active_calls(): calls = [] for key in r.scan_iter("active_call:*"): call = r.hgetall(key) calls.append(call) return calls
# On the NFS server
apt-get install nfs-kernel-server
mkdir -p /exports/asterisk-recordings
echo '/exports/asterisk-recordings *(rw,sync,no_subtree_check,no_root_squash)' >> /etc/exports
exportfs -ra
# On the NFS server
apt-get install nfs-kernel-server
mkdir -p /exports/asterisk-recordings
echo '/exports/asterisk-recordings *(rw,sync,no_subtree_check,no_root_squash)' >> /etc/exports
exportfs -ra
# On the NFS server
apt-get install nfs-kernel-server
mkdir -p /exports/asterisk-recordings
echo '/exports/asterisk-recordings *(rw,sync,no_subtree_check,no_root_squash)' >> /etc/exports
exportfs -ra
#!/bin/bash
# upload-recording.sh - Called by Asterisk MixMonitor as post-recording command
# MixMonitor(/var/spool/asterisk/monitor/${UNIQUEID}.wav,b,/usr/local/bin/upload-recording.sh ^{UNIQUEID}) RECORDING_FILE="/var/spool/asterisk/monitor/$1.wav"
S3_BUCKET="s3://your-bucket/recordings" if [ -f "$RECORDING_FILE" ]; then aws s3 cp "$RECORDING_FILE" "$S3_BUCKET/$(date +%Y/%m/%d)/$1.wav" \ --storage-class STANDARD_IA # Optionally delete local copy after upload # rm "$RECORDING_FILE"
fi
#!/bin/bash
# upload-recording.sh - Called by Asterisk MixMonitor as post-recording command
# MixMonitor(/var/spool/asterisk/monitor/${UNIQUEID}.wav,b,/usr/local/bin/upload-recording.sh ^{UNIQUEID}) RECORDING_FILE="/var/spool/asterisk/monitor/$1.wav"
S3_BUCKET="s3://your-bucket/recordings" if [ -f "$RECORDING_FILE" ]; then aws s3 cp "$RECORDING_FILE" "$S3_BUCKET/$(date +%Y/%m/%d)/$1.wav" \ --storage-class STANDARD_IA # Optionally delete local copy after upload # rm "$RECORDING_FILE"
fi
#!/bin/bash
# upload-recording.sh - Called by Asterisk MixMonitor as post-recording command
# MixMonitor(/var/spool/asterisk/monitor/${UNIQUEID}.wav,b,/usr/local/bin/upload-recording.sh ^{UNIQUEID}) RECORDING_FILE="/var/spool/asterisk/monitor/$1.wav"
S3_BUCKET="s3://your-bucket/recordings" if [ -f "$RECORDING_FILE" ]; then aws s3 cp "$RECORDING_FILE" "$S3_BUCKET/$(date +%Y/%m/%d)/$1.wav" \ --storage-class STANDARD_IA # Optionally delete local copy after upload # rm "$RECORDING_FILE"
fi
# 1. Build the new image
docker build -t asterisk:21-v2 ./asterisk/ # 2. Start new container alongside the old one
docker compose up -d --no-deps --scale asterisk=2 asterisk # 3. Verify the new container is healthy
docker compose ps # 4. Drain calls from the old container
# (stop sending new calls to it via Kamailio dispatcher)
# Wait for active calls to finish naturally # 5. Remove the old container
docker compose up -d --no-deps --scale asterisk=1 asterisk # 6. Or use rolling updates (Docker Compose does not natively
# support this, but Docker Swarm/Kubernetes do)
# 1. Build the new image
docker build -t asterisk:21-v2 ./asterisk/ # 2. Start new container alongside the old one
docker compose up -d --no-deps --scale asterisk=2 asterisk # 3. Verify the new container is healthy
docker compose ps # 4. Drain calls from the old container
# (stop sending new calls to it via Kamailio dispatcher)
# Wait for active calls to finish naturally # 5. Remove the old container
docker compose up -d --no-deps --scale asterisk=1 asterisk # 6. Or use rolling updates (Docker Compose does not natively
# support this, but Docker Swarm/Kubernetes do)
# 1. Build the new image
docker build -t asterisk:21-v2 ./asterisk/ # 2. Start new container alongside the old one
docker compose up -d --no-deps --scale asterisk=2 asterisk # 3. Verify the new container is healthy
docker compose ps # 4. Drain calls from the old container
# (stop sending new calls to it via Kamailio dispatcher)
# Wait for active calls to finish naturally # 5. Remove the old container
docker compose up -d --no-deps --scale asterisk=1 asterisk # 6. Or use rolling updates (Docker Compose does not natively
# support this, but Docker Swarm/Kubernetes do)
SECURITY
[ ] All default passwords changed in .env
[ ] .env file permissions: chmod 600 .env
[ ] AMI port (5038) NOT exposed externally
[ ] ARI port (8088) NOT exposed externally (proxied through Nginx)
[ ] MariaDB port (3306) NOT exposed externally
[ ] Redis requires password
[ ] TLS certificates installed and valid
[ ] fail2ban configured on the Docker host (not in containers)
[ ] Docker daemon not exposed over TCP
[ ] No secrets in Dockerfile or docker-compose.yml (use .env)
[ ] Container runs as non-root (asterisk user inside container) NETWORKING
[ ] RTP port range mapped correctly (matches rtp.conf)
[ ] external_media_address set to public IP in PJSIP
[ ] external_signaling_address set to public IP in PJSIP
[ ] local_net includes Docker bridge subnet
[ ] Firewall allows SIP (5060-5061), RTP (10000-20000), HTTPS (443)
[ ] NAT tested: make a call from external phone, verify two-way audio
[ ] DNS A record points to server for TLS/WebSocket STORAGE
[ ] Recording directory has adequate disk space
[ ] Recording cleanup cron job installed
[ ] Log rotation configured
[ ] Database backup cron job installed
[ ] Backup restoration tested at least once
[ ] Volumes use bind mounts for critical data (not anonymous volumes) PERFORMANCE
[ ] Resource limits set in docker-compose.yml (CPU, memory)
[ ] ulimits set (nofile: 65536)
[ ] MariaDB innodb_buffer_pool_size tuned for available RAM
[ ] Redis maxmemory set with eviction policy
[ ] Docker logging driver limits set (max-size, max-file) MONITORING
[ ] Health checks configured for all containers
[ ] Prometheus exporter running and scraping
[ ] Log shipping to central logging (Loki/ELK)
[ ] Alerting configured for: container down, disk full, no audio, trunk down
[ ] Dashboard accessible in Grafana OPERATIONAL
[ ] docker-compose.yml version-controlled (Git)
[ ] .env file NOT in version control (in .gitignore)
[ ] Documented procedure for: restart, upgrade, rollback, restore
[ ] Team knows how to access Asterisk CLI: docker compose exec asterisk asterisk -rvvv
[ ] Test call verified: internal, inbound, outbound, voicemail, conference
SECURITY
[ ] All default passwords changed in .env
[ ] .env file permissions: chmod 600 .env
[ ] AMI port (5038) NOT exposed externally
[ ] ARI port (8088) NOT exposed externally (proxied through Nginx)
[ ] MariaDB port (3306) NOT exposed externally
[ ] Redis requires password
[ ] TLS certificates installed and valid
[ ] fail2ban configured on the Docker host (not in containers)
[ ] Docker daemon not exposed over TCP
[ ] No secrets in Dockerfile or docker-compose.yml (use .env)
[ ] Container runs as non-root (asterisk user inside container) NETWORKING
[ ] RTP port range mapped correctly (matches rtp.conf)
[ ] external_media_address set to public IP in PJSIP
[ ] external_signaling_address set to public IP in PJSIP
[ ] local_net includes Docker bridge subnet
[ ] Firewall allows SIP (5060-5061), RTP (10000-20000), HTTPS (443)
[ ] NAT tested: make a call from external phone, verify two-way audio
[ ] DNS A record points to server for TLS/WebSocket STORAGE
[ ] Recording directory has adequate disk space
[ ] Recording cleanup cron job installed
[ ] Log rotation configured
[ ] Database backup cron job installed
[ ] Backup restoration tested at least once
[ ] Volumes use bind mounts for critical data (not anonymous volumes) PERFORMANCE
[ ] Resource limits set in docker-compose.yml (CPU, memory)
[ ] ulimits set (nofile: 65536)
[ ] MariaDB innodb_buffer_pool_size tuned for available RAM
[ ] Redis maxmemory set with eviction policy
[ ] Docker logging driver limits set (max-size, max-file) MONITORING
[ ] Health checks configured for all containers
[ ] Prometheus exporter running and scraping
[ ] Log shipping to central logging (Loki/ELK)
[ ] Alerting configured for: container down, disk full, no audio, trunk down
[ ] Dashboard accessible in Grafana OPERATIONAL
[ ] docker-compose.yml version-controlled (Git)
[ ] .env file NOT in version control (in .gitignore)
[ ] Documented procedure for: restart, upgrade, rollback, restore
[ ] Team knows how to access Asterisk CLI: docker compose exec asterisk asterisk -rvvv
[ ] Test call verified: internal, inbound, outbound, voicemail, conference
SECURITY
[ ] All default passwords changed in .env
[ ] .env file permissions: chmod 600 .env
[ ] AMI port (5038) NOT exposed externally
[ ] ARI port (8088) NOT exposed externally (proxied through Nginx)
[ ] MariaDB port (3306) NOT exposed externally
[ ] Redis requires password
[ ] TLS certificates installed and valid
[ ] fail2ban configured on the Docker host (not in containers)
[ ] Docker daemon not exposed over TCP
[ ] No secrets in Dockerfile or docker-compose.yml (use .env)
[ ] Container runs as non-root (asterisk user inside container) NETWORKING
[ ] RTP port range mapped correctly (matches rtp.conf)
[ ] external_media_address set to public IP in PJSIP
[ ] external_signaling_address set to public IP in PJSIP
[ ] local_net includes Docker bridge subnet
[ ] Firewall allows SIP (5060-5061), RTP (10000-20000), HTTPS (443)
[ ] NAT tested: make a call from external phone, verify two-way audio
[ ] DNS A record points to server for TLS/WebSocket STORAGE
[ ] Recording directory has adequate disk space
[ ] Recording cleanup cron job installed
[ ] Log rotation configured
[ ] Database backup cron job installed
[ ] Backup restoration tested at least once
[ ] Volumes use bind mounts for critical data (not anonymous volumes) PERFORMANCE
[ ] Resource limits set in docker-compose.yml (CPU, memory)
[ ] ulimits set (nofile: 65536)
[ ] MariaDB innodb_buffer_pool_size tuned for available RAM
[ ] Redis maxmemory set with eviction policy
[ ] Docker logging driver limits set (max-size, max-file) MONITORING
[ ] Health checks configured for all containers
[ ] Prometheus exporter running and scraping
[ ] Log shipping to central logging (Loki/ELK)
[ ] Alerting configured for: container down, disk full, no audio, trunk down
[ ] Dashboard accessible in Grafana OPERATIONAL
[ ] docker-compose.yml version-controlled (Git)
[ ] .env file NOT in version control (in .gitignore)
[ ] Documented procedure for: restart, upgrade, rollback, restore
[ ] Team knows how to access Asterisk CLI: docker compose exec asterisk asterisk -rvvv
[ ] Test call verified: internal, inbound, outbound, voicemail, conference
# Add to the asterisk service in docker-compose.yml:
asterisk: # ... existing config ... security_opt: - no-new-privileges:true read_only: true # Read-only root filesystem tmpfs: - /tmp:size=100M - /var/run/asterisk:size=10M cap_drop: - ALL cap_add: - NET_BIND_SERVICE # Bind to ports < 1024 (5060) - SYS_NICE # Set process priority (for real-time audio) - NET_RAW # For ICMP (qualify/keepalive)
# Add to the asterisk service in docker-compose.yml:
asterisk: # ... existing config ... security_opt: - no-new-privileges:true read_only: true # Read-only root filesystem tmpfs: - /tmp:size=100M - /var/run/asterisk:size=10M cap_drop: - ALL cap_add: - NET_BIND_SERVICE # Bind to ports < 1024 (5060) - SYS_NICE # Set process priority (for real-time audio) - NET_RAW # For ICMP (qualify/keepalive)
# Add to the asterisk service in docker-compose.yml:
asterisk: # ... existing config ... security_opt: - no-new-privileges:true read_only: true # Read-only root filesystem tmpfs: - /tmp:size=100M - /var/run/asterisk:size=10M cap_drop: - ALL cap_add: - NET_BIND_SERVICE # Bind to ports < 1024 (5060) - SYS_NICE # Set process priority (for real-time audio) - NET_RAW # For ICMP (qualify/keepalive)
Symptom: Call connects, you can see the call in the CLI, but no audio.
Symptom: Call connects, you can see the call in the CLI, but no audio.
Symptom: Call connects, you can see the call in the CLI, but no audio.
# Check 1: Is external_media_address set correctly?
docker compose exec asterisk asterisk -rx "pjsip show transport transport-udp" | grep external # Check 2: Are RTP ports mapped?
docker compose exec asterisk ss -ulnp | grep 10000 # Check 3: Is RTP traffic reaching the container?
# On the Docker host:
tcpdump -i any -n udp portrange 10000-20000 -c 20 # Check 4: Is direct_media disabled?
docker compose exec asterisk asterisk -rx "pjsip show endpoint 1001" | grep direct_media # Fix: Switch to host networking for quick resolution
# In docker-compose.yml: network_mode: host
# Check 1: Is external_media_address set correctly?
docker compose exec asterisk asterisk -rx "pjsip show transport transport-udp" | grep external # Check 2: Are RTP ports mapped?
docker compose exec asterisk ss -ulnp | grep 10000 # Check 3: Is RTP traffic reaching the container?
# On the Docker host:
tcpdump -i any -n udp portrange 10000-20000 -c 20 # Check 4: Is direct_media disabled?
docker compose exec asterisk asterisk -rx "pjsip show endpoint 1001" | grep direct_media # Fix: Switch to host networking for quick resolution
# In docker-compose.yml: network_mode: host
# Check 1: Is external_media_address set correctly?
docker compose exec asterisk asterisk -rx "pjsip show transport transport-udp" | grep external # Check 2: Are RTP ports mapped?
docker compose exec asterisk ss -ulnp | grep 10000 # Check 3: Is RTP traffic reaching the container?
# On the Docker host:
tcpdump -i any -n udp portrange 10000-20000 -c 20 # Check 4: Is direct_media disabled?
docker compose exec asterisk asterisk -rx "pjsip show endpoint 1001" | grep direct_media # Fix: Switch to host networking for quick resolution
# In docker-compose.yml: network_mode: host
Symptom: SIP phones cannot register, see "401 Unauthorized" or timeout.
Symptom: SIP phones cannot register, see "401 Unauthorized" or timeout.
Symptom: SIP phones cannot register, see "401 Unauthorized" or timeout.
# Check 1: Is PJSIP listening?
docker compose exec asterisk asterisk -rx "pjsip show transports" # Check 2: Enable SIP logging
docker compose exec asterisk asterisk -rx "pjsip set logger on"
# Look for incoming REGISTER and the response # Check 3: Verify credentials
docker compose exec asterisk asterisk -rx "pjsip show endpoint 1001"
docker compose exec asterisk asterisk -rx "pjsip show auth 1001" # Check 4: Is the port reachable from outside?
# From another machine:
nmap -sU -p 5060 YOUR_SERVER_IP # Check 5: Firewall
iptables -L -n | grep 5060
# Check 1: Is PJSIP listening?
docker compose exec asterisk asterisk -rx "pjsip show transports" # Check 2: Enable SIP logging
docker compose exec asterisk asterisk -rx "pjsip set logger on"
# Look for incoming REGISTER and the response # Check 3: Verify credentials
docker compose exec asterisk asterisk -rx "pjsip show endpoint 1001"
docker compose exec asterisk asterisk -rx "pjsip show auth 1001" # Check 4: Is the port reachable from outside?
# From another machine:
nmap -sU -p 5060 YOUR_SERVER_IP # Check 5: Firewall
iptables -L -n | grep 5060
# Check 1: Is PJSIP listening?
docker compose exec asterisk asterisk -rx "pjsip show transports" # Check 2: Enable SIP logging
docker compose exec asterisk asterisk -rx "pjsip set logger on"
# Look for incoming REGISTER and the response # Check 3: Verify credentials
docker compose exec asterisk asterisk -rx "pjsip show endpoint 1001"
docker compose exec asterisk asterisk -rx "pjsip show auth 1001" # Check 4: Is the port reachable from outside?
# From another machine:
nmap -sU -p 5060 YOUR_SERVER_IP # Check 5: Firewall
iptables -L -n | grep 5060
Symptom: Call fails with "488 Not Acceptable Here" or no compatible codecs.
Symptom: Call fails with "488 Not Acceptable Here" or no compatible codecs.
Symptom: Call fails with "488 Not Acceptable Here" or no compatible codecs.
# Check allowed codecs on the endpoint
docker compose exec asterisk asterisk -rx "pjsip show endpoint 1001" | grep -i allow # Check what codecs are actually loaded
docker compose exec asterisk asterisk -rx "core show codecs" # Verify Opus is loaded (if using WebRTC)
docker compose exec asterisk asterisk -rx "module show like opus" # Fix: ensure both sides support at least one common codec
# Most compatible: allow = ulaw,alaw (G.711)
# Best for WebRTC: allow = opus,ulaw
# Check allowed codecs on the endpoint
docker compose exec asterisk asterisk -rx "pjsip show endpoint 1001" | grep -i allow # Check what codecs are actually loaded
docker compose exec asterisk asterisk -rx "core show codecs" # Verify Opus is loaded (if using WebRTC)
docker compose exec asterisk asterisk -rx "module show like opus" # Fix: ensure both sides support at least one common codec
# Most compatible: allow = ulaw,alaw (G.711)
# Best for WebRTC: allow = opus,ulaw
# Check allowed codecs on the endpoint
docker compose exec asterisk asterisk -rx "pjsip show endpoint 1001" | grep -i allow # Check what codecs are actually loaded
docker compose exec asterisk asterisk -rx "core show codecs" # Verify Opus is loaded (if using WebRTC)
docker compose exec asterisk asterisk -rx "module show like opus" # Fix: ensure both sides support at least one common codec
# Most compatible: allow = ulaw,alaw (G.711)
# Best for WebRTC: allow = opus,ulaw
Symptom: Asterisk cannot resolve SIP trunk hostnames, outbound calls fail.
Symptom: Asterisk cannot resolve SIP trunk hostnames, outbound calls fail.
Symptom: Asterisk cannot resolve SIP trunk hostnames, outbound calls fail.
# Check DNS from inside the container
docker compose exec asterisk nslookup sip.provider.example.com # Check Docker DNS configuration
docker compose exec asterisk cat /etc/resolv.conf # Fix: Add explicit DNS servers in docker-compose.yml
asterisk: dns: - 8.8.8.8 - 1.1.1.1
# Check DNS from inside the container
docker compose exec asterisk nslookup sip.provider.example.com # Check Docker DNS configuration
docker compose exec asterisk cat /etc/resolv.conf # Fix: Add explicit DNS servers in docker-compose.yml
asterisk: dns: - 8.8.8.8 - 1.1.1.1
# Check DNS from inside the container
docker compose exec asterisk nslookup sip.provider.example.com # Check Docker DNS configuration
docker compose exec asterisk cat /etc/resolv.conf # Fix: Add explicit DNS servers in docker-compose.yml
asterisk: dns: - 8.8.8.8 - 1.1.1.1
# Check logs for crash reason
docker compose logs --tail=50 asterisk # Check if it's an OOM kill
docker inspect asterisk | grep -A5 "State"
dmesg | grep -i "oom\|killed" # Check resource limits
docker stats asterisk --no-stream # Common causes:
# - Module loading error (missing dependency)
# - Configuration syntax error
# - Port conflict with another container
# - Insufficient memory limit
# Check logs for crash reason
docker compose logs --tail=50 asterisk # Check if it's an OOM kill
docker inspect asterisk | grep -A5 "State"
dmesg | grep -i "oom\|killed" # Check resource limits
docker stats asterisk --no-stream # Common causes:
# - Module loading error (missing dependency)
# - Configuration syntax error
# - Port conflict with another container
# - Insufficient memory limit
# Check logs for crash reason
docker compose logs --tail=50 asterisk # Check if it's an OOM kill
docker inspect asterisk | grep -A5 "State"
dmesg | grep -i "oom\|killed" # Check resource limits
docker stats asterisk --no-stream # Common causes:
# - Module loading error (missing dependency)
# - Configuration syntax error
# - Port conflict with another container
# - Insufficient memory limit
Symptom: docker compose up takes 30+ seconds for the asterisk container.
Symptom: docker compose up takes 30+ seconds for the asterisk container.
Symptom: docker compose up takes 30+ seconds for the asterisk container.
# Docker creates iptables rules for each port in the range.
# 10000 ports = 10000 iptables rules. # Fix 1: Narrow the port range
# rtp.conf: rtpend = 10200
# docker-compose.yml: ports: "10000-10200:10000-10200/udp" # Fix 2: Use host networking (no port mapping needed)
# network_mode: host # Fix 3: Use nftables backend instead of iptables (faster)
# /etc/docker/daemon.json: {"iptables": false}
# Then manage nftables rules manually
# Docker creates iptables rules for each port in the range.
# 10000 ports = 10000 iptables rules. # Fix 1: Narrow the port range
# rtp.conf: rtpend = 10200
# docker-compose.yml: ports: "10000-10200:10000-10200/udp" # Fix 2: Use host networking (no port mapping needed)
# network_mode: host # Fix 3: Use nftables backend instead of iptables (faster)
# /etc/docker/daemon.json: {"iptables": false}
# Then manage nftables rules manually
# Docker creates iptables rules for each port in the range.
# 10000 ports = 10000 iptables rules. # Fix 1: Narrow the port range
# rtp.conf: rtpend = 10200
# docker-compose.yml: ports: "10000-10200:10000-10200/udp" # Fix 2: Use host networking (no port mapping needed)
# network_mode: host # Fix 3: Use nftables backend instead of iptables (faster)
# /etc/docker/daemon.json: {"iptables": false}
# Then manage nftables rules manually
# ---- Container Management ----
docker compose up -d # Start all services
docker compose down # Stop all services
docker compose restart asterisk # Restart Asterisk only
docker compose logs -f asterisk # Follow Asterisk logs
docker compose ps # Show container status
docker compose top # Show running processes # ---- Asterisk CLI ----
docker compose exec asterisk asterisk -rvvv # Interactive CLI
docker compose exec asterisk asterisk -rx "core show channels"
docker compose exec asterisk asterisk -rx "pjsip show endpoints"
docker compose exec asterisk asterisk -rx "pjsip show registrations"
docker compose exec asterisk asterisk -rx "confbridge list"
docker compose exec asterisk asterisk -rx "core show uptime"
docker compose exec asterisk asterisk -rx "module reload" # ---- Database ----
docker compose exec mariadb mysql -u asterisk -p asterisk
docker compose exec mariadb mysql -u asterisk -p asterisk \ -e "SELECT COUNT(*) as total_calls, DATE(calldate) as day FROM cdr GROUP BY day ORDER BY day DESC LIMIT 7;" # ---- Debugging ----
docker compose exec asterisk sngrep # SIP packet capture
docker compose exec asterisk tcpdump -i any -n -w /tmp/capture.pcap port 5060
docker compose exec asterisk asterisk -rx "pjsip set logger on"
docker compose exec asterisk asterisk -rx "rtp set debug on" # ---- Upgrades ----
docker compose build --no-cache asterisk # Rebuild with latest source
docker compose up -d asterisk # Replace running container
docker compose exec asterisk asterisk -V # Verify new version # ---- Backup ----
/opt/asterisk-docker/scripts/backup.sh # Manual backup
ls -lh /opt/asterisk-docker/backups/ # List backups
# ---- Container Management ----
docker compose up -d # Start all services
docker compose down # Stop all services
docker compose restart asterisk # Restart Asterisk only
docker compose logs -f asterisk # Follow Asterisk logs
docker compose ps # Show container status
docker compose top # Show running processes # ---- Asterisk CLI ----
docker compose exec asterisk asterisk -rvvv # Interactive CLI
docker compose exec asterisk asterisk -rx "core show channels"
docker compose exec asterisk asterisk -rx "pjsip show endpoints"
docker compose exec asterisk asterisk -rx "pjsip show registrations"
docker compose exec asterisk asterisk -rx "confbridge list"
docker compose exec asterisk asterisk -rx "core show uptime"
docker compose exec asterisk asterisk -rx "module reload" # ---- Database ----
docker compose exec mariadb mysql -u asterisk -p asterisk
docker compose exec mariadb mysql -u asterisk -p asterisk \ -e "SELECT COUNT(*) as total_calls, DATE(calldate) as day FROM cdr GROUP BY day ORDER BY day DESC LIMIT 7;" # ---- Debugging ----
docker compose exec asterisk sngrep # SIP packet capture
docker compose exec asterisk tcpdump -i any -n -w /tmp/capture.pcap port 5060
docker compose exec asterisk asterisk -rx "pjsip set logger on"
docker compose exec asterisk asterisk -rx "rtp set debug on" # ---- Upgrades ----
docker compose build --no-cache asterisk # Rebuild with latest source
docker compose up -d asterisk # Replace running container
docker compose exec asterisk asterisk -V # Verify new version # ---- Backup ----
/opt/asterisk-docker/scripts/backup.sh # Manual backup
ls -lh /opt/asterisk-docker/backups/ # List backups
# ---- Container Management ----
docker compose up -d # Start all services
docker compose down # Stop all services
docker compose restart asterisk # Restart Asterisk only
docker compose logs -f asterisk # Follow Asterisk logs
docker compose ps # Show container status
docker compose top # Show running processes # ---- Asterisk CLI ----
docker compose exec asterisk asterisk -rvvv # Interactive CLI
docker compose exec asterisk asterisk -rx "core show channels"
docker compose exec asterisk asterisk -rx "pjsip show endpoints"
docker compose exec asterisk asterisk -rx "pjsip show registrations"
docker compose exec asterisk asterisk -rx "confbridge list"
docker compose exec asterisk asterisk -rx "core show uptime"
docker compose exec asterisk asterisk -rx "module reload" # ---- Database ----
docker compose exec mariadb mysql -u asterisk -p asterisk
docker compose exec mariadb mysql -u asterisk -p asterisk \ -e "SELECT COUNT(*) as total_calls, DATE(calldate) as day FROM cdr GROUP BY day ORDER BY day DESC LIMIT 7;" # ---- Debugging ----
docker compose exec asterisk sngrep # SIP packet capture
docker compose exec asterisk tcpdump -i any -n -w /tmp/capture.pcap port 5060
docker compose exec asterisk asterisk -rx "pjsip set logger on"
docker compose exec asterisk asterisk -rx "rtp set debug on" # ---- Upgrades ----
docker compose build --no-cache asterisk # Rebuild with latest source
docker compose up -d asterisk # Replace running container
docker compose exec asterisk asterisk -V # Verify new version # ---- Backup ----
/opt/asterisk-docker/scripts/backup.sh # Manual backup
ls -lh /opt/asterisk-docker/backups/ # List backups - Introduction
- Architecture Overview
- Prerequisites
- Dockerfile Deep Dive
- Docker Compose Stack
- Asterisk Configuration Templates
- RTP & NAT Challenges
- Persistent Storage Strategy
- Database Integration
- ARI (Asterisk REST Interface)
- WebRTC Support
- Monitoring & Logging
- Scaling & High Availability
- Production Checklist & Troubleshooting - Snowflake servers: Every Asterisk box drifts from the others over time. Different module versions, different patches, different configs. When one breaks, you cannot reproduce the issue anywhere else.
- Upgrades are terrifying: Upgrading Asterisk on a production server means recompiling on the live box, hoping dependencies do not break, and praying the new version does not regress your codec or SRTP support.
- Environment dependencies: Asterisk links against specific versions of libpjproject, libsrtp, libopus, and dozens of other libraries. A system update can silently break things.
- No rollback path: If an upgrade goes wrong, you are restoring from backup and hoping your config files match the binary. - ViciDial: ViciDial is deeply coupled to its host OS. It expects specific paths, cron jobs, screen sessions, Apache with mod_php, and direct filesystem access for recordings. Containerizing ViciDial is a multi-month project that most teams should avoid. Use VMs.
- DAHDI timing: If you need MeetMe conferencing (not ConfBridge), you need DAHDI kernel modules for timing. DAHDI requires kernel module compilation on the host, which defeats much of the container isolation benefit. ConfBridge uses res_timing_timerfd instead and works perfectly in containers.
- Ultra-low latency requirements: Docker's bridge networking adds ~50-100 microseconds of latency per packet. For most VoIP this is negligible, but if you are doing carrier-grade media processing at scale, test carefully.
- Existing stable deployments: If you have a bare-metal Asterisk that has been running for years and you are the only one who touches it, containerization adds complexity without clear benefit. Do not fix what is not broken. - Module selection: Include only what you need (PJSIP, ARI, ConfBridge, Opus) and exclude what you do not (chan_sip, chan_dahdi, res_phoneprov)
- Codec support: Compile with Opus, which is not included in most distribution packages
- Reproducible builds: The exact same binary every time, regardless of when you build
- Security: Smaller image = smaller attack surface. No compiler, no headers, no build tools in production
┌─────────────────────────────────────────────────────────────────────┐
│ Docker Compose Stack │
│ │
│ ┌─────────────┐ ┌──────────────┐ ┌─────────────────────┐ │
│ │ Nginx │ │ Asterisk │ │ MariaDB │ │
│ │ (reverse │◄──►│ 21 LTS │◄──►│ (CDR, realtime │ │
│ │ proxy, │ │ │ │ config, voicemail) │ │
│ │ TLS, │ │ PJSIP │ │ │ │
│ │ WebSocket)│ │ ARI │ └─────────────────────┘ │
│ └──────┬──────┘ │ ConfBridge │ │
│ │ │ Voicemail │ ┌─────────────────────┐ │
│ │ │ │◄──►│ Redis │ │
│ ┌──────┴──────┐ └──────┬───────┘ │ (ARI state, │ │
│ │ Certbot │ │ │ session cache) │ │
│ │ (Let's │ ┌──────┴───────┐ └─────────────────────┘ │
│ │ Encrypt) │ │ Volumes │ │
│ └─────────────┘ │ recordings/ │ ┌─────────────────────┐ │
│ │ voicemail/ │ │ Coturn │ │
│ │ logs/ │ │ (TURN/STUN for │ │
│ │ certs/ │ │ WebRTC NAT) │ │
│ └──────────────┘ └─────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘ External Traffic: ├── SIP/TLS (TCP 5061) ──────────────────► Asterisk PJSIP ├── SIP (UDP 5060) ─────────────────────► Asterisk PJSIP ├── RTP (UDP 10000-20000) ──────────────► Asterisk Media ├── HTTPS (TCP 443) ────────────────────► Nginx ──► ARI / WebRTC ├── HTTP (TCP 80) ──────────────────────► Nginx ──► Certbot / redirect └── TURN (UDP/TCP 3478, UDP 49152-65535) ► Coturn
┌─────────────────────────────────────────────────────────────────────┐
│ Docker Compose Stack │
│ │
│ ┌─────────────┐ ┌──────────────┐ ┌─────────────────────┐ │
│ │ Nginx │ │ Asterisk │ │ MariaDB │ │
│ │ (reverse │◄──►│ 21 LTS │◄──►│ (CDR, realtime │ │
│ │ proxy, │ │ │ │ config, voicemail) │ │
│ │ TLS, │ │ PJSIP │ │ │ │
│ │ WebSocket)│ │ ARI │ └─────────────────────┘ │
│ └──────┬──────┘ │ ConfBridge │ │
│ │ │ Voicemail │ ┌─────────────────────┐ │
│ │ │ │◄──►│ Redis │ │
│ ┌──────┴──────┐ └──────┬───────┘ │ (ARI state, │ │
│ │ Certbot │ │ │ session cache) │ │
│ │ (Let's │ ┌──────┴───────┐ └─────────────────────┘ │
│ │ Encrypt) │ │ Volumes │ │
│ └─────────────┘ │ recordings/ │ ┌─────────────────────┐ │
│ │ voicemail/ │ │ Coturn │ │
│ │ logs/ │ │ (TURN/STUN for │ │
│ │ certs/ │ │ WebRTC NAT) │ │
│ └──────────────┘ └─────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘ External Traffic: ├── SIP/TLS (TCP 5061) ──────────────────► Asterisk PJSIP ├── SIP (UDP 5060) ─────────────────────► Asterisk PJSIP ├── RTP (UDP 10000-20000) ──────────────► Asterisk Media ├── HTTPS (TCP 443) ────────────────────► Nginx ──► ARI / WebRTC ├── HTTP (TCP 80) ──────────────────────► Nginx ──► Certbot / redirect └── TURN (UDP/TCP 3478, UDP 49152-65535) ► Coturn
┌─────────────────────────────────────────────────────────────────────┐
│ Docker Compose Stack │
│ │
│ ┌─────────────┐ ┌──────────────┐ ┌─────────────────────┐ │
│ │ Nginx │ │ Asterisk │ │ MariaDB │ │
│ │ (reverse │◄──►│ 21 LTS │◄──►│ (CDR, realtime │ │
│ │ proxy, │ │ │ │ config, voicemail) │ │
│ │ TLS, │ │ PJSIP │ │ │ │
│ │ WebSocket)│ │ ARI │ └─────────────────────┘ │
│ └──────┬──────┘ │ ConfBridge │ │
│ │ │ Voicemail │ ┌─────────────────────┐ │
│ │ │ │◄──►│ Redis │ │
│ ┌──────┴──────┐ └──────┬───────┘ │ (ARI state, │ │
│ │ Certbot │ │ │ session cache) │ │
│ │ (Let's │ ┌──────┴───────┐ └─────────────────────┘ │
│ │ Encrypt) │ │ Volumes │ │
│ └─────────────┘ │ recordings/ │ ┌─────────────────────┐ │
│ │ voicemail/ │ │ Coturn │ │
│ │ logs/ │ │ (TURN/STUN for │ │
│ │ certs/ │ │ WebRTC NAT) │ │
│ └──────────────┘ └─────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘ External Traffic: ├── SIP/TLS (TCP 5061) ──────────────────► Asterisk PJSIP ├── SIP (UDP 5060) ─────────────────────► Asterisk PJSIP ├── RTP (UDP 10000-20000) ──────────────► Asterisk Media ├── HTTPS (TCP 443) ────────────────────► Nginx ──► ARI / WebRTC ├── HTTP (TCP 80) ──────────────────────► Nginx ──► Certbot / redirect └── TURN (UDP/TCP 3478, UDP 49152-65535) ► Coturn
asterisk-docker/
├── docker-compose.yml
├── .env
├── asterisk/
│ ├── Dockerfile
│ ├── entrypoint.sh
│ ├── configs/
│ │ ├── pjsip.conf.template
│ │ ├── extensions.conf.template
│ │ ├── modules.conf
│ │ ├── http.conf.template
│ │ ├── rtp.conf
│ │ ├── ari.conf.template
│ │ ├── cdr.conf
│ │ ├── cdr_adaptive_odbc.conf
│ │ ├── res_odbc.conf.template
│ │ ├── odbc.ini.template
│ │ └── voicemail.conf.template
│ └── sounds/
│ └── custom/
├── mariadb/
│ └── init/
│ └── 01-schema.sql
├── nginx/
│ ├── nginx.conf
│ └── conf.d/
│ └── asterisk.conf.template
├── coturn/
│ └── turnserver.conf.template
├── ari-app/
│ ├── requirements.txt
│ └── app.py
├── scripts/
│ ├── backup.sh
│ └── restore.sh
└── data/ # Created by Docker volumes ├── mariadb/ ├── redis/ ├── recordings/ ├── voicemail/ ├── logs/ └── certs/
asterisk-docker/
├── docker-compose.yml
├── .env
├── asterisk/
│ ├── Dockerfile
│ ├── entrypoint.sh
│ ├── configs/
│ │ ├── pjsip.conf.template
│ │ ├── extensions.conf.template
│ │ ├── modules.conf
│ │ ├── http.conf.template
│ │ ├── rtp.conf
│ │ ├── ari.conf.template
│ │ ├── cdr.conf
│ │ ├── cdr_adaptive_odbc.conf
│ │ ├── res_odbc.conf.template
│ │ ├── odbc.ini.template
│ │ └── voicemail.conf.template
│ └── sounds/
│ └── custom/
├── mariadb/
│ └── init/
│ └── 01-schema.sql
├── nginx/
│ ├── nginx.conf
│ └── conf.d/
│ └── asterisk.conf.template
├── coturn/
│ └── turnserver.conf.template
├── ari-app/
│ ├── requirements.txt
│ └── app.py
├── scripts/
│ ├── backup.sh
│ └── restore.sh
└── data/ # Created by Docker volumes ├── mariadb/ ├── redis/ ├── recordings/ ├── voicemail/ ├── logs/ └── certs/
asterisk-docker/
├── docker-compose.yml
├── .env
├── asterisk/
│ ├── Dockerfile
│ ├── entrypoint.sh
│ ├── configs/
│ │ ├── pjsip.conf.template
│ │ ├── extensions.conf.template
│ │ ├── modules.conf
│ │ ├── http.conf.template
│ │ ├── rtp.conf
│ │ ├── ari.conf.template
│ │ ├── cdr.conf
│ │ ├── cdr_adaptive_odbc.conf
│ │ ├── res_odbc.conf.template
│ │ ├── odbc.ini.template
│ │ └── voicemail.conf.template
│ └── sounds/
│ └── custom/
├── mariadb/
│ └── init/
│ └── 01-schema.sql
├── nginx/
│ ├── nginx.conf
│ └── conf.d/
│ └── asterisk.conf.template
├── coturn/
│ └── turnserver.conf.template
├── ari-app/
│ ├── requirements.txt
│ └── app.py
├── scripts/
│ ├── backup.sh
│ └── restore.sh
└── data/ # Created by Docker volumes ├── mariadb/ ├── redis/ ├── recordings/ ├── voicemail/ ├── logs/ └── certs/
# Remove old Docker packages
sudo apt-get remove -y docker docker-engine docker.io containerd runc 2>/dev/null # Install prerequisites
sudo apt-get update
sudo apt-get install -y ca-certificates curl gnupg # Add Docker GPG key
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | \ sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg # Add Docker repository
echo \ "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \ https://download.docker.com/linux/ubuntu \ $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \ sudo tee /etc/apt/sources.list.d/docker.list > /dev/null # Install Docker Engine + Compose plugin
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io \ docker-buildx-plugin docker-compose-plugin # Verify
docker --version # Docker 24.x or newer
docker compose version # v2.x
# Remove old Docker packages
sudo apt-get remove -y docker docker-engine docker.io containerd runc 2>/dev/null # Install prerequisites
sudo apt-get update
sudo apt-get install -y ca-certificates curl gnupg # Add Docker GPG key
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | \ sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg # Add Docker repository
echo \ "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \ https://download.docker.com/linux/ubuntu \ $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \ sudo tee /etc/apt/sources.list.d/docker.list > /dev/null # Install Docker Engine + Compose plugin
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io \ docker-buildx-plugin docker-compose-plugin # Verify
docker --version # Docker 24.x or newer
docker compose version # v2.x
# Remove old Docker packages
sudo apt-get remove -y docker docker-engine docker.io containerd runc 2>/dev/null # Install prerequisites
sudo apt-get update
sudo apt-get install -y ca-certificates curl gnupg # Add Docker GPG key
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | \ sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg # Add Docker repository
echo \ "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \ https://download.docker.com/linux/ubuntu \ $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \ sudo tee /etc/apt/sources.list.d/docker.list > /dev/null # Install Docker Engine + Compose plugin
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io \ docker-buildx-plugin docker-compose-plugin # Verify
docker --version # Docker 24.x or newer
docker compose version # v2.x
# SIP signaling
sudo ufw allow 5060/udp # SIP over UDP
sudo ufw allow 5060/tcp # SIP over TCP
sudo ufw allow 5061/tcp # SIP over TLS # RTP media
sudo ufw allow 10000:20000/udp # Web (HTTPS, HTTP for ACME challenge)
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp # TURN server (for WebRTC)
sudo ufw allow 3478/udp
sudo ufw allow 3478/tcp
sudo ufw allow 49152:65535/udp # Enable firewall
sudo ufw enable
# SIP signaling
sudo ufw allow 5060/udp # SIP over UDP
sudo ufw allow 5060/tcp # SIP over TCP
sudo ufw allow 5061/tcp # SIP over TLS # RTP media
sudo ufw allow 10000:20000/udp # Web (HTTPS, HTTP for ACME challenge)
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp # TURN server (for WebRTC)
sudo ufw allow 3478/udp
sudo ufw allow 3478/tcp
sudo ufw allow 49152:65535/udp # Enable firewall
sudo ufw enable
# SIP signaling
sudo ufw allow 5060/udp # SIP over UDP
sudo ufw allow 5060/tcp # SIP over TCP
sudo ufw allow 5061/tcp # SIP over TLS # RTP media
sudo ufw allow 10000:20000/udp # Web (HTTPS, HTTP for ACME challenge)
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp # TURN server (for WebRTC)
sudo ufw allow 3478/udp
sudo ufw allow 3478/tcp
sudo ufw allow 49152:65535/udp # Enable firewall
sudo ufw enable
mkdir -p /opt/asterisk-docker/{asterisk/{configs,sounds/custom},mariadb/init,nginx/conf.d,coturn,ari-app,scripts,data}
cd /opt/asterisk-docker
mkdir -p /opt/asterisk-docker/{asterisk/{configs,sounds/custom},mariadb/init,nginx/conf.d,coturn,ari-app,scripts,data}
cd /opt/asterisk-docker
mkdir -p /opt/asterisk-docker/{asterisk/{configs,sounds/custom},mariadb/init,nginx/conf.d,coturn,ari-app,scripts,data}
cd /opt/asterisk-docker
# =============================================================================
# Multi-stage Dockerfile for Asterisk 21 LTS
# Stage 1: Build Asterisk from source with selected modules
# Stage 2: Minimal runtime image with only what's needed
# ============================================================================= # -----------------------------------------------------------------------------
# Stage 1: Builder
# -----------------------------------------------------------------------------
FROM debian:bookworm-slim AS builder ARG ASTERISK_VERSION=21.7.0
ARG OPUS_VERSION=1.5.2 # Avoid interactive prompts during build
ENV DEBIAN_FRONTEND=noninteractive # Install build dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \ build-essential \ ca-certificates \ curl \ wget \ git \ pkg-config \ autoconf \ automake \ libtool \ # Asterisk core dependencies libjansson-dev \ libxml2-dev \ libncurses5-dev \ libsqlite3-dev \ uuid-dev \ libssl-dev \ # PJSIP dependencies libpjproject-dev \ # Sound/codec dependencies libspeex-dev \ libspeexdsp-dev \ libopus-dev \ libsndfile1-dev \ libvorbis-dev \ # Database/ODBC dependencies unixodbc-dev \ libmariadb-dev \ odbc-mariadb \ # Lua for dialplan (optional) liblua5.4-dev \ # HTTP/WebSocket dependencies (for ARI) libcurl4-openssl-dev \ # SRTP for encrypted media libsrtp2-dev \ # Editing/misc libedit-dev \ libxslt1-dev \ && rm -rf /var/lib/apt/lists/* # Download and extract Asterisk source
WORKDIR /usr/src
RUN curl -fsSL "https://downloads.asterisk.org/pub/telephony/asterisk/asterisk-${ASTERISK_VERSION}.tar.gz" \ -o asterisk.tar.gz \ && tar xzf asterisk.tar.gz \ && mv asterisk-${ASTERISK_VERSION} asterisk \ && rm asterisk.tar.gz WORKDIR /usr/src/asterisk # Install MP3 source (for mp3 playback support)
RUN contrib/scripts/get_mp3_source.sh || true # Configure Asterisk
# --with-pjproject-bundled downloads and builds pjproject matched to Asterisk version
# If system libpjproject is sufficient, use --with-pjproject instead
RUN ./configure \ --prefix=/usr \ --sysconfdir=/etc \ --localstatedir=/var \ --with-crypto \ --with-ssl \ --with-srtp \ --with-pjproject-bundled \ --with-jansson-bundled \ --with-opus \ --without-dahdi \ --without-pri \ --without-radius \ --without-postgres \ --without-sdl \ --without-gtk2 \ --without-x11 # Select modules to build using menuselect
# Enable: PJSIP, ARI, ConfBridge, Opus, Voicemail ODBC, CDR adaptive ODBC
# Disable: chan_sip (deprecated), DAHDI, unnecessary resource hogs
RUN make menuselect.makeopts \ # ---- Enable essential modules ---- && menuselect/menuselect \ --enable res_pjsip \ --enable res_pjsip_session \ --enable res_pjsip_authenticator_digest \ --enable res_pjsip_caller_id \ --enable res_pjsip_endpoint_identifier_ip \ --enable res_pjsip_endpoint_identifier_user \ --enable res_pjsip_header_funcs \ --enable res_pjsip_nat \ --enable res_pjsip_outbound_authenticator_digest \ --enable res_pjsip_outbound_registration \ --enable res_pjsip_registrar \ --enable res_pjsip_sdp_rtp \ --enable res_pjsip_transport_websocket \ --enable res_pjsip_dtmf_info \ # ARI and HTTP --enable res_ari \ --enable res_ari_channels \ --enable res_ari_bridges \ --enable res_ari_endpoints \ --enable res_ari_recordings \ --enable res_ari_playbacks \ --enable res_ari_sounds \ --enable res_ari_events \ --enable res_http_websocket \ --enable res_stasis \ --enable res_stasis_answer \ --enable res_stasis_playback \ --enable res_stasis_recording \ --enable res_stasis_snoop \ # Conferencing (ConfBridge, no DAHDI needed) --enable app_confbridge \ --enable app_audiosocket \ # Codecs --enable codec_opus \ --enable codec_speex \ --enable codec_resample \ # Database / CDR --enable cdr_adaptive_odbc \ --enable cdr_csv \ --enable func_odbc \ --enable res_odbc \ --enable res_odbc_transaction \ # Voicemail with ODBC storage --enable app_voicemail_odbc \ # Timing (timerfd works without DAHDI) --enable res_timing_timerfd \ # Format support --enable format_mp3 \ --enable format_wav \ --enable format_wav_gsm \ --enable format_sln \ --enable format_ogg_vorbis \ # ---- Disable what we don't need ---- --disable chan_sip \ --disable chan_dahdi \ --disable chan_skinny \ --disable chan_mgcp \ --disable chan_unistim \ --disable app_meetme \ --disable res_timing_dahdi \ --disable cdr_pgsql \ --disable cel_pgsql \ menuselect.makeopts # Build Asterisk (use all available cores)
RUN make -j$(nproc) # Install to staging directory
RUN make install DESTDIR=/tmp/asterisk-install \ && make samples DESTDIR=/tmp/asterisk-install # Download standard English sound files (GSM + WAV formats)
RUN make progdocs || true # Download core sound packs to staging
RUN mkdir -p /tmp/asterisk-install/var/lib/asterisk/sounds/en \ && cd /tmp/asterisk-install/var/lib/asterisk/sounds/en \ && for pack in core extra; do \ for fmt in gsm wav; do \ curl -fsSL "https://downloads.asterisk.org/pub/telephony/sounds/asterisk-${pack}-sounds-en-${fmt}-current.tar.gz" \ | tar xz 2>/dev/null || true; \ done; \ done # Download MOH files
RUN mkdir -p /tmp/asterisk-install/var/lib/asterisk/moh \ && cd /tmp/asterisk-install/var/lib/asterisk/moh \ && curl -fsSL "https://downloads.asterisk.org/pub/telephony/sounds/asterisk-moh-opsound-wav-current.tar.gz" \ | tar xz 2>/dev/null || true # -----------------------------------------------------------------------------
# Stage 2: Runtime
# -----------------------------------------------------------------------------
FROM debian:bookworm-slim AS runtime LABEL maintainer="[email protected]"
LABEL description="Asterisk 21 LTS - Production Container"
LABEL version="1.0" ENV DEBIAN_FRONTEND=noninteractive # Install only runtime dependencies (no compilers, no -dev packages)
RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates \ curl \ # Core runtime libs libjansson4 \ libxml2 \ libncurses6 \ libsqlite3-0 \ libuuid1 \ libssl3 \ # Codec runtime libs libspeex1 \ libspeexdsp1 \ libopus0 \ libsndfile1 \ libvorbis0a \ libvorbisenc2 \ # Database/ODBC runtime unixodbc \ libmariadb3 \ odbc-mariadb \ # SRTP libsrtp2-1 \ # HTTP libcurl4 \ # Editing libedit2 \ # Lua runtime liblua5.4-0 \ # envsubst for config templating gettext-base \ # Useful debugging tools (remove in ultra-minimal builds) sngrep \ tcpdump \ net-tools \ iputils-ping \ dnsutils \ procps \ && rm -rf /var/lib/apt/lists/* # Create asterisk user and group
RUN groupadd -r asterisk && useradd -r -g asterisk -s /bin/false asterisk # Copy compiled Asterisk from builder stage
COPY --from=builder /tmp/asterisk-install/ / # Create required directories with proper ownership
RUN mkdir -p \ /var/run/asterisk \ /var/log/asterisk \ /var/log/asterisk/cdr-csv \ /var/spool/asterisk/monitor \ /var/spool/asterisk/voicemail \ /var/spool/asterisk/tmp \ /var/lib/asterisk \ /etc/asterisk \ && chown -R asterisk:asterisk \ /var/run/asterisk \ /var/log/asterisk \ /var/spool/asterisk \ /var/lib/asterisk \ /etc/asterisk # Copy entrypoint script
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh # Health check - verify Asterisk is responding
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ CMD asterisk -rx "core show version" || exit 1 # Expose ports
# 5060: SIP UDP/TCP
# 5061: SIP TLS
# 8088: HTTP/ARI/WebSocket
# 8089: HTTPS/ARI/WebSocket
# 5038: AMI
# 10000-20000: RTP media
EXPOSE 5060/udp 5060/tcp 5061/tcp 8088/tcp 8089/tcp 5038/tcp
EXPOSE 10000-20000/udp # Run as root initially (entrypoint drops privileges)
ENTRYPOINT ["/entrypoint.sh"]
CMD ["asterisk", "-fp"]
# =============================================================================
# Multi-stage Dockerfile for Asterisk 21 LTS
# Stage 1: Build Asterisk from source with selected modules
# Stage 2: Minimal runtime image with only what's needed
# ============================================================================= # -----------------------------------------------------------------------------
# Stage 1: Builder
# -----------------------------------------------------------------------------
FROM debian:bookworm-slim AS builder ARG ASTERISK_VERSION=21.7.0
ARG OPUS_VERSION=1.5.2 # Avoid interactive prompts during build
ENV DEBIAN_FRONTEND=noninteractive # Install build dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \ build-essential \ ca-certificates \ curl \ wget \ git \ pkg-config \ autoconf \ automake \ libtool \ # Asterisk core dependencies libjansson-dev \ libxml2-dev \ libncurses5-dev \ libsqlite3-dev \ uuid-dev \ libssl-dev \ # PJSIP dependencies libpjproject-dev \ # Sound/codec dependencies libspeex-dev \ libspeexdsp-dev \ libopus-dev \ libsndfile1-dev \ libvorbis-dev \ # Database/ODBC dependencies unixodbc-dev \ libmariadb-dev \ odbc-mariadb \ # Lua for dialplan (optional) liblua5.4-dev \ # HTTP/WebSocket dependencies (for ARI) libcurl4-openssl-dev \ # SRTP for encrypted media libsrtp2-dev \ # Editing/misc libedit-dev \ libxslt1-dev \ && rm -rf /var/lib/apt/lists/* # Download and extract Asterisk source
WORKDIR /usr/src
RUN curl -fsSL "https://downloads.asterisk.org/pub/telephony/asterisk/asterisk-${ASTERISK_VERSION}.tar.gz" \ -o asterisk.tar.gz \ && tar xzf asterisk.tar.gz \ && mv asterisk-${ASTERISK_VERSION} asterisk \ && rm asterisk.tar.gz WORKDIR /usr/src/asterisk # Install MP3 source (for mp3 playback support)
RUN contrib/scripts/get_mp3_source.sh || true # Configure Asterisk
# --with-pjproject-bundled downloads and builds pjproject matched to Asterisk version
# If system libpjproject is sufficient, use --with-pjproject instead
RUN ./configure \ --prefix=/usr \ --sysconfdir=/etc \ --localstatedir=/var \ --with-crypto \ --with-ssl \ --with-srtp \ --with-pjproject-bundled \ --with-jansson-bundled \ --with-opus \ --without-dahdi \ --without-pri \ --without-radius \ --without-postgres \ --without-sdl \ --without-gtk2 \ --without-x11 # Select modules to build using menuselect
# Enable: PJSIP, ARI, ConfBridge, Opus, Voicemail ODBC, CDR adaptive ODBC
# Disable: chan_sip (deprecated), DAHDI, unnecessary resource hogs
RUN make menuselect.makeopts \ # ---- Enable essential modules ---- && menuselect/menuselect \ --enable res_pjsip \ --enable res_pjsip_session \ --enable res_pjsip_authenticator_digest \ --enable res_pjsip_caller_id \ --enable res_pjsip_endpoint_identifier_ip \ --enable res_pjsip_endpoint_identifier_user \ --enable res_pjsip_header_funcs \ --enable res_pjsip_nat \ --enable res_pjsip_outbound_authenticator_digest \ --enable res_pjsip_outbound_registration \ --enable res_pjsip_registrar \ --enable res_pjsip_sdp_rtp \ --enable res_pjsip_transport_websocket \ --enable res_pjsip_dtmf_info \ # ARI and HTTP --enable res_ari \ --enable res_ari_channels \ --enable res_ari_bridges \ --enable res_ari_endpoints \ --enable res_ari_recordings \ --enable res_ari_playbacks \ --enable res_ari_sounds \ --enable res_ari_events \ --enable res_http_websocket \ --enable res_stasis \ --enable res_stasis_answer \ --enable res_stasis_playback \ --enable res_stasis_recording \ --enable res_stasis_snoop \ # Conferencing (ConfBridge, no DAHDI needed) --enable app_confbridge \ --enable app_audiosocket \ # Codecs --enable codec_opus \ --enable codec_speex \ --enable codec_resample \ # Database / CDR --enable cdr_adaptive_odbc \ --enable cdr_csv \ --enable func_odbc \ --enable res_odbc \ --enable res_odbc_transaction \ # Voicemail with ODBC storage --enable app_voicemail_odbc \ # Timing (timerfd works without DAHDI) --enable res_timing_timerfd \ # Format support --enable format_mp3 \ --enable format_wav \ --enable format_wav_gsm \ --enable format_sln \ --enable format_ogg_vorbis \ # ---- Disable what we don't need ---- --disable chan_sip \ --disable chan_dahdi \ --disable chan_skinny \ --disable chan_mgcp \ --disable chan_unistim \ --disable app_meetme \ --disable res_timing_dahdi \ --disable cdr_pgsql \ --disable cel_pgsql \ menuselect.makeopts # Build Asterisk (use all available cores)
RUN make -j$(nproc) # Install to staging directory
RUN make install DESTDIR=/tmp/asterisk-install \ && make samples DESTDIR=/tmp/asterisk-install # Download standard English sound files (GSM + WAV formats)
RUN make progdocs || true # Download core sound packs to staging
RUN mkdir -p /tmp/asterisk-install/var/lib/asterisk/sounds/en \ && cd /tmp/asterisk-install/var/lib/asterisk/sounds/en \ && for pack in core extra; do \ for fmt in gsm wav; do \ curl -fsSL "https://downloads.asterisk.org/pub/telephony/sounds/asterisk-${pack}-sounds-en-${fmt}-current.tar.gz" \ | tar xz 2>/dev/null || true; \ done; \ done # Download MOH files
RUN mkdir -p /tmp/asterisk-install/var/lib/asterisk/moh \ && cd /tmp/asterisk-install/var/lib/asterisk/moh \ && curl -fsSL "https://downloads.asterisk.org/pub/telephony/sounds/asterisk-moh-opsound-wav-current.tar.gz" \ | tar xz 2>/dev/null || true # -----------------------------------------------------------------------------
# Stage 2: Runtime
# -----------------------------------------------------------------------------
FROM debian:bookworm-slim AS runtime LABEL maintainer="[email protected]"
LABEL description="Asterisk 21 LTS - Production Container"
LABEL version="1.0" ENV DEBIAN_FRONTEND=noninteractive # Install only runtime dependencies (no compilers, no -dev packages)
RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates \ curl \ # Core runtime libs libjansson4 \ libxml2 \ libncurses6 \ libsqlite3-0 \ libuuid1 \ libssl3 \ # Codec runtime libs libspeex1 \ libspeexdsp1 \ libopus0 \ libsndfile1 \ libvorbis0a \ libvorbisenc2 \ # Database/ODBC runtime unixodbc \ libmariadb3 \ odbc-mariadb \ # SRTP libsrtp2-1 \ # HTTP libcurl4 \ # Editing libedit2 \ # Lua runtime liblua5.4-0 \ # envsubst for config templating gettext-base \ # Useful debugging tools (remove in ultra-minimal builds) sngrep \ tcpdump \ net-tools \ iputils-ping \ dnsutils \ procps \ && rm -rf /var/lib/apt/lists/* # Create asterisk user and group
RUN groupadd -r asterisk && useradd -r -g asterisk -s /bin/false asterisk # Copy compiled Asterisk from builder stage
COPY --from=builder /tmp/asterisk-install/ / # Create required directories with proper ownership
RUN mkdir -p \ /var/run/asterisk \ /var/log/asterisk \ /var/log/asterisk/cdr-csv \ /var/spool/asterisk/monitor \ /var/spool/asterisk/voicemail \ /var/spool/asterisk/tmp \ /var/lib/asterisk \ /etc/asterisk \ && chown -R asterisk:asterisk \ /var/run/asterisk \ /var/log/asterisk \ /var/spool/asterisk \ /var/lib/asterisk \ /etc/asterisk # Copy entrypoint script
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh # Health check - verify Asterisk is responding
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ CMD asterisk -rx "core show version" || exit 1 # Expose ports
# 5060: SIP UDP/TCP
# 5061: SIP TLS
# 8088: HTTP/ARI/WebSocket
# 8089: HTTPS/ARI/WebSocket
# 5038: AMI
# 10000-20000: RTP media
EXPOSE 5060/udp 5060/tcp 5061/tcp 8088/tcp 8089/tcp 5038/tcp
EXPOSE 10000-20000/udp # Run as root initially (entrypoint drops privileges)
ENTRYPOINT ["/entrypoint.sh"]
CMD ["asterisk", "-fp"]
# =============================================================================
# Multi-stage Dockerfile for Asterisk 21 LTS
# Stage 1: Build Asterisk from source with selected modules
# Stage 2: Minimal runtime image with only what's needed
# ============================================================================= # -----------------------------------------------------------------------------
# Stage 1: Builder
# -----------------------------------------------------------------------------
FROM debian:bookworm-slim AS builder ARG ASTERISK_VERSION=21.7.0
ARG OPUS_VERSION=1.5.2 # Avoid interactive prompts during build
ENV DEBIAN_FRONTEND=noninteractive # Install build dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \ build-essential \ ca-certificates \ curl \ wget \ git \ pkg-config \ autoconf \ automake \ libtool \ # Asterisk core dependencies libjansson-dev \ libxml2-dev \ libncurses5-dev \ libsqlite3-dev \ uuid-dev \ libssl-dev \ # PJSIP dependencies libpjproject-dev \ # Sound/codec dependencies libspeex-dev \ libspeexdsp-dev \ libopus-dev \ libsndfile1-dev \ libvorbis-dev \ # Database/ODBC dependencies unixodbc-dev \ libmariadb-dev \ odbc-mariadb \ # Lua for dialplan (optional) liblua5.4-dev \ # HTTP/WebSocket dependencies (for ARI) libcurl4-openssl-dev \ # SRTP for encrypted media libsrtp2-dev \ # Editing/misc libedit-dev \ libxslt1-dev \ && rm -rf /var/lib/apt/lists/* # Download and extract Asterisk source
WORKDIR /usr/src
RUN curl -fsSL "https://downloads.asterisk.org/pub/telephony/asterisk/asterisk-${ASTERISK_VERSION}.tar.gz" \ -o asterisk.tar.gz \ && tar xzf asterisk.tar.gz \ && mv asterisk-${ASTERISK_VERSION} asterisk \ && rm asterisk.tar.gz WORKDIR /usr/src/asterisk # Install MP3 source (for mp3 playback support)
RUN contrib/scripts/get_mp3_source.sh || true # Configure Asterisk
# --with-pjproject-bundled downloads and builds pjproject matched to Asterisk version
# If system libpjproject is sufficient, use --with-pjproject instead
RUN ./configure \ --prefix=/usr \ --sysconfdir=/etc \ --localstatedir=/var \ --with-crypto \ --with-ssl \ --with-srtp \ --with-pjproject-bundled \ --with-jansson-bundled \ --with-opus \ --without-dahdi \ --without-pri \ --without-radius \ --without-postgres \ --without-sdl \ --without-gtk2 \ --without-x11 # Select modules to build using menuselect
# Enable: PJSIP, ARI, ConfBridge, Opus, Voicemail ODBC, CDR adaptive ODBC
# Disable: chan_sip (deprecated), DAHDI, unnecessary resource hogs
RUN make menuselect.makeopts \ # ---- Enable essential modules ---- && menuselect/menuselect \ --enable res_pjsip \ --enable res_pjsip_session \ --enable res_pjsip_authenticator_digest \ --enable res_pjsip_caller_id \ --enable res_pjsip_endpoint_identifier_ip \ --enable res_pjsip_endpoint_identifier_user \ --enable res_pjsip_header_funcs \ --enable res_pjsip_nat \ --enable res_pjsip_outbound_authenticator_digest \ --enable res_pjsip_outbound_registration \ --enable res_pjsip_registrar \ --enable res_pjsip_sdp_rtp \ --enable res_pjsip_transport_websocket \ --enable res_pjsip_dtmf_info \ # ARI and HTTP --enable res_ari \ --enable res_ari_channels \ --enable res_ari_bridges \ --enable res_ari_endpoints \ --enable res_ari_recordings \ --enable res_ari_playbacks \ --enable res_ari_sounds \ --enable res_ari_events \ --enable res_http_websocket \ --enable res_stasis \ --enable res_stasis_answer \ --enable res_stasis_playback \ --enable res_stasis_recording \ --enable res_stasis_snoop \ # Conferencing (ConfBridge, no DAHDI needed) --enable app_confbridge \ --enable app_audiosocket \ # Codecs --enable codec_opus \ --enable codec_speex \ --enable codec_resample \ # Database / CDR --enable cdr_adaptive_odbc \ --enable cdr_csv \ --enable func_odbc \ --enable res_odbc \ --enable res_odbc_transaction \ # Voicemail with ODBC storage --enable app_voicemail_odbc \ # Timing (timerfd works without DAHDI) --enable res_timing_timerfd \ # Format support --enable format_mp3 \ --enable format_wav \ --enable format_wav_gsm \ --enable format_sln \ --enable format_ogg_vorbis \ # ---- Disable what we don't need ---- --disable chan_sip \ --disable chan_dahdi \ --disable chan_skinny \ --disable chan_mgcp \ --disable chan_unistim \ --disable app_meetme \ --disable res_timing_dahdi \ --disable cdr_pgsql \ --disable cel_pgsql \ menuselect.makeopts # Build Asterisk (use all available cores)
RUN make -j$(nproc) # Install to staging directory
RUN make install DESTDIR=/tmp/asterisk-install \ && make samples DESTDIR=/tmp/asterisk-install # Download standard English sound files (GSM + WAV formats)
RUN make progdocs || true # Download core sound packs to staging
RUN mkdir -p /tmp/asterisk-install/var/lib/asterisk/sounds/en \ && cd /tmp/asterisk-install/var/lib/asterisk/sounds/en \ && for pack in core extra; do \ for fmt in gsm wav; do \ curl -fsSL "https://downloads.asterisk.org/pub/telephony/sounds/asterisk-${pack}-sounds-en-${fmt}-current.tar.gz" \ | tar xz 2>/dev/null || true; \ done; \ done # Download MOH files
RUN mkdir -p /tmp/asterisk-install/var/lib/asterisk/moh \ && cd /tmp/asterisk-install/var/lib/asterisk/moh \ && curl -fsSL "https://downloads.asterisk.org/pub/telephony/sounds/asterisk-moh-opsound-wav-current.tar.gz" \ | tar xz 2>/dev/null || true # -----------------------------------------------------------------------------
# Stage 2: Runtime
# -----------------------------------------------------------------------------
FROM debian:bookworm-slim AS runtime LABEL maintainer="[email protected]"
LABEL description="Asterisk 21 LTS - Production Container"
LABEL version="1.0" ENV DEBIAN_FRONTEND=noninteractive # Install only runtime dependencies (no compilers, no -dev packages)
RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates \ curl \ # Core runtime libs libjansson4 \ libxml2 \ libncurses6 \ libsqlite3-0 \ libuuid1 \ libssl3 \ # Codec runtime libs libspeex1 \ libspeexdsp1 \ libopus0 \ libsndfile1 \ libvorbis0a \ libvorbisenc2 \ # Database/ODBC runtime unixodbc \ libmariadb3 \ odbc-mariadb \ # SRTP libsrtp2-1 \ # HTTP libcurl4 \ # Editing libedit2 \ # Lua runtime liblua5.4-0 \ # envsubst for config templating gettext-base \ # Useful debugging tools (remove in ultra-minimal builds) sngrep \ tcpdump \ net-tools \ iputils-ping \ dnsutils \ procps \ && rm -rf /var/lib/apt/lists/* # Create asterisk user and group
RUN groupadd -r asterisk && useradd -r -g asterisk -s /bin/false asterisk # Copy compiled Asterisk from builder stage
COPY --from=builder /tmp/asterisk-install/ / # Create required directories with proper ownership
RUN mkdir -p \ /var/run/asterisk \ /var/log/asterisk \ /var/log/asterisk/cdr-csv \ /var/spool/asterisk/monitor \ /var/spool/asterisk/voicemail \ /var/spool/asterisk/tmp \ /var/lib/asterisk \ /etc/asterisk \ && chown -R asterisk:asterisk \ /var/run/asterisk \ /var/log/asterisk \ /var/spool/asterisk \ /var/lib/asterisk \ /etc/asterisk # Copy entrypoint script
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh # Health check - verify Asterisk is responding
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ CMD asterisk -rx "core show version" || exit 1 # Expose ports
# 5060: SIP UDP/TCP
# 5061: SIP TLS
# 8088: HTTP/ARI/WebSocket
# 8089: HTTPS/ARI/WebSocket
# 5038: AMI
# 10000-20000: RTP media
EXPOSE 5060/udp 5060/tcp 5061/tcp 8088/tcp 8089/tcp 5038/tcp
EXPOSE 10000-20000/udp # Run as root initially (entrypoint drops privileges)
ENTRYPOINT ["/entrypoint.sh"]
CMD ["asterisk", "-fp"]
#!/bin/bash
set -e # =============================================================================
# Asterisk Container Entrypoint
# - Processes config templates (envsubst)
# - Sets up ODBC configuration
# - Fixes permissions
# - Starts Asterisk with proper flags
# ============================================================================= echo "=== Asterisk Container Starting ==="
echo "Hostname: $(hostname)"
echo "Date: $(date -u '+%Y-%m-%d %H:%M:%S UTC')" # ---- Step 1: Process configuration templates ----
# Any file ending in .template gets processed with envsubst
# Environment variables like ${PJSIP_EXTERNAL_IP} are replaced with values
echo "Processing configuration templates..." TEMPLATE_DIR="/etc/asterisk/templates"
CONFIG_DIR="/etc/asterisk" if [ -d "$TEMPLATE_DIR" ]; then for template in "$TEMPLATE_DIR"/*.template; do [ -f "$template" ] || continue filename=$(basename "$template" .template) echo " Rendering: $filename" envsubst < "$template" > "$CONFIG_DIR/$filename" done
fi # ---- Step 2: Process ODBC configuration ----
if [ -f "/etc/odbc.ini.template" ]; then echo "Rendering ODBC configuration..." envsubst < /etc/odbc.ini.template > /etc/odbc.ini
fi # ---- Step 3: Set NAT/network variables ----
# Auto-detect public IP if not provided
if [ -z "$PJSIP_EXTERNAL_IP" ]; then echo "PJSIP_EXTERNAL_IP not set, auto-detecting..." PJSIP_EXTERNAL_IP=$(curl -s -4 --max-time 5 https://ifconfig.me || echo "") if [ -n "$PJSIP_EXTERNAL_IP" ]; then echo " Detected external IP: $PJSIP_EXTERNAL_IP" export PJSIP_EXTERNAL_IP else echo " WARNING: Could not detect external IP. NAT may not work correctly." fi
fi # Re-process templates now that PJSIP_EXTERNAL_IP might be set
if [ -d "$TEMPLATE_DIR" ]; then for template in "$TEMPLATE_DIR"/*.template; do [ -f "$template" ] || continue filename=$(basename "$template" .template) envsubst < "$template" > "$CONFIG_DIR/$filename" done
fi # ---- Step 4: Fix permissions ----
echo "Setting permissions..."
chown -R asterisk:asterisk /var/run/asterisk
chown -R asterisk:asterisk /var/log/asterisk
chown -R asterisk:asterisk /var/spool/asterisk
chown -R asterisk:asterisk /etc/asterisk
chown -R asterisk:asterisk /var/lib/asterisk # ---- Step 5: Wait for database ----
if [ -n "$DB_HOST" ]; then echo "Waiting for database at $DB_HOST:${DB_PORT:-3306}..." for i in $(seq 1 30); do if curl -sf "telnet://$DB_HOST:${DB_PORT:-3306}" --max-time 2 >/dev/null 2>&1 || \ bash -c "echo >/dev/tcp/$DB_HOST/${DB_PORT:-3306}" 2>/dev/null; then echo " Database is ready!" break fi echo " Attempt $i/30 - waiting..." sleep 2 done
fi # ---- Step 6: Start Asterisk ----
echo "Starting Asterisk..."
echo "Command: $@" # If the command is 'asterisk', run as the asterisk user
if [ "$1" = "asterisk" ]; then exec "$@" -U asterisk -G asterisk
else exec "$@"
fi
#!/bin/bash
set -e # =============================================================================
# Asterisk Container Entrypoint
# - Processes config templates (envsubst)
# - Sets up ODBC configuration
# - Fixes permissions
# - Starts Asterisk with proper flags
# ============================================================================= echo "=== Asterisk Container Starting ==="
echo "Hostname: $(hostname)"
echo "Date: $(date -u '+%Y-%m-%d %H:%M:%S UTC')" # ---- Step 1: Process configuration templates ----
# Any file ending in .template gets processed with envsubst
# Environment variables like ${PJSIP_EXTERNAL_IP} are replaced with values
echo "Processing configuration templates..." TEMPLATE_DIR="/etc/asterisk/templates"
CONFIG_DIR="/etc/asterisk" if [ -d "$TEMPLATE_DIR" ]; then for template in "$TEMPLATE_DIR"/*.template; do [ -f "$template" ] || continue filename=$(basename "$template" .template) echo " Rendering: $filename" envsubst < "$template" > "$CONFIG_DIR/$filename" done
fi # ---- Step 2: Process ODBC configuration ----
if [ -f "/etc/odbc.ini.template" ]; then echo "Rendering ODBC configuration..." envsubst < /etc/odbc.ini.template > /etc/odbc.ini
fi # ---- Step 3: Set NAT/network variables ----
# Auto-detect public IP if not provided
if [ -z "$PJSIP_EXTERNAL_IP" ]; then echo "PJSIP_EXTERNAL_IP not set, auto-detecting..." PJSIP_EXTERNAL_IP=$(curl -s -4 --max-time 5 https://ifconfig.me || echo "") if [ -n "$PJSIP_EXTERNAL_IP" ]; then echo " Detected external IP: $PJSIP_EXTERNAL_IP" export PJSIP_EXTERNAL_IP else echo " WARNING: Could not detect external IP. NAT may not work correctly." fi
fi # Re-process templates now that PJSIP_EXTERNAL_IP might be set
if [ -d "$TEMPLATE_DIR" ]; then for template in "$TEMPLATE_DIR"/*.template; do [ -f "$template" ] || continue filename=$(basename "$template" .template) envsubst < "$template" > "$CONFIG_DIR/$filename" done
fi # ---- Step 4: Fix permissions ----
echo "Setting permissions..."
chown -R asterisk:asterisk /var/run/asterisk
chown -R asterisk:asterisk /var/log/asterisk
chown -R asterisk:asterisk /var/spool/asterisk
chown -R asterisk:asterisk /etc/asterisk
chown -R asterisk:asterisk /var/lib/asterisk # ---- Step 5: Wait for database ----
if [ -n "$DB_HOST" ]; then echo "Waiting for database at $DB_HOST:${DB_PORT:-3306}..." for i in $(seq 1 30); do if curl -sf "telnet://$DB_HOST:${DB_PORT:-3306}" --max-time 2 >/dev/null 2>&1 || \ bash -c "echo >/dev/tcp/$DB_HOST/${DB_PORT:-3306}" 2>/dev/null; then echo " Database is ready!" break fi echo " Attempt $i/30 - waiting..." sleep 2 done
fi # ---- Step 6: Start Asterisk ----
echo "Starting Asterisk..."
echo "Command: $@" # If the command is 'asterisk', run as the asterisk user
if [ "$1" = "asterisk" ]; then exec "$@" -U asterisk -G asterisk
else exec "$@"
fi
#!/bin/bash
set -e # =============================================================================
# Asterisk Container Entrypoint
# - Processes config templates (envsubst)
# - Sets up ODBC configuration
# - Fixes permissions
# - Starts Asterisk with proper flags
# ============================================================================= echo "=== Asterisk Container Starting ==="
echo "Hostname: $(hostname)"
echo "Date: $(date -u '+%Y-%m-%d %H:%M:%S UTC')" # ---- Step 1: Process configuration templates ----
# Any file ending in .template gets processed with envsubst
# Environment variables like ${PJSIP_EXTERNAL_IP} are replaced with values
echo "Processing configuration templates..." TEMPLATE_DIR="/etc/asterisk/templates"
CONFIG_DIR="/etc/asterisk" if [ -d "$TEMPLATE_DIR" ]; then for template in "$TEMPLATE_DIR"/*.template; do [ -f "$template" ] || continue filename=$(basename "$template" .template) echo " Rendering: $filename" envsubst < "$template" > "$CONFIG_DIR/$filename" done
fi # ---- Step 2: Process ODBC configuration ----
if [ -f "/etc/odbc.ini.template" ]; then echo "Rendering ODBC configuration..." envsubst < /etc/odbc.ini.template > /etc/odbc.ini
fi # ---- Step 3: Set NAT/network variables ----
# Auto-detect public IP if not provided
if [ -z "$PJSIP_EXTERNAL_IP" ]; then echo "PJSIP_EXTERNAL_IP not set, auto-detecting..." PJSIP_EXTERNAL_IP=$(curl -s -4 --max-time 5 https://ifconfig.me || echo "") if [ -n "$PJSIP_EXTERNAL_IP" ]; then echo " Detected external IP: $PJSIP_EXTERNAL_IP" export PJSIP_EXTERNAL_IP else echo " WARNING: Could not detect external IP. NAT may not work correctly." fi
fi # Re-process templates now that PJSIP_EXTERNAL_IP might be set
if [ -d "$TEMPLATE_DIR" ]; then for template in "$TEMPLATE_DIR"/*.template; do [ -f "$template" ] || continue filename=$(basename "$template" .template) envsubst < "$template" > "$CONFIG_DIR/$filename" done
fi # ---- Step 4: Fix permissions ----
echo "Setting permissions..."
chown -R asterisk:asterisk /var/run/asterisk
chown -R asterisk:asterisk /var/log/asterisk
chown -R asterisk:asterisk /var/spool/asterisk
chown -R asterisk:asterisk /etc/asterisk
chown -R asterisk:asterisk /var/lib/asterisk # ---- Step 5: Wait for database ----
if [ -n "$DB_HOST" ]; then echo "Waiting for database at $DB_HOST:${DB_PORT:-3306}..." for i in $(seq 1 30); do if curl -sf "telnet://$DB_HOST:${DB_PORT:-3306}" --max-time 2 >/dev/null 2>&1 || \ bash -c "echo >/dev/tcp/$DB_HOST/${DB_PORT:-3306}" 2>/dev/null; then echo " Database is ready!" break fi echo " Attempt $i/30 - waiting..." sleep 2 done
fi # ---- Step 6: Start Asterisk ----
echo "Starting Asterisk..."
echo "Command: $@" # If the command is 'asterisk', run as the asterisk user
if [ "$1" = "asterisk" ]; then exec "$@" -U asterisk -G asterisk
else exec "$@"
fi
cd /opt/asterisk-docker # Build the image (takes 10-20 minutes on first build)
docker build -t asterisk:21-custom ./asterisk/ # Verify the image size
docker images asterisk:21-custom
# REPOSITORY TAG SIZE
# asterisk 21-custom ~250MB # Quick smoke test
docker run --rm asterisk:21-custom asterisk -V
# Asterisk 21.7.0 # Test module loading
docker run --rm asterisk:21-custom asterisk -rx "module show like pjsip" 2>/dev/null | head -5
cd /opt/asterisk-docker # Build the image (takes 10-20 minutes on first build)
docker build -t asterisk:21-custom ./asterisk/ # Verify the image size
docker images asterisk:21-custom
# REPOSITORY TAG SIZE
# asterisk 21-custom ~250MB # Quick smoke test
docker run --rm asterisk:21-custom asterisk -V
# Asterisk 21.7.0 # Test module loading
docker run --rm asterisk:21-custom asterisk -rx "module show like pjsip" 2>/dev/null | head -5
cd /opt/asterisk-docker # Build the image (takes 10-20 minutes on first build)
docker build -t asterisk:21-custom ./asterisk/ # Verify the image size
docker images asterisk:21-custom
# REPOSITORY TAG SIZE
# asterisk 21-custom ~250MB # Quick smoke test
docker run --rm asterisk:21-custom asterisk -V
# Asterisk 21.7.0 # Test module loading
docker run --rm asterisk:21-custom asterisk -rx "module show like pjsip" 2>/dev/null | head -5
# Build with a different Asterisk version
docker build --build-arg ASTERISK_VERSION=21.8.0 -t asterisk:21.8 ./asterisk/ # Build with Asterisk 20 LTS instead
docker build --build-arg ASTERISK_VERSION=20.11.0 -t asterisk:20 ./asterisk/
# Build with a different Asterisk version
docker build --build-arg ASTERISK_VERSION=21.8.0 -t asterisk:21.8 ./asterisk/ # Build with Asterisk 20 LTS instead
docker build --build-arg ASTERISK_VERSION=20.11.0 -t asterisk:20 ./asterisk/
# Build with a different Asterisk version
docker build --build-arg ASTERISK_VERSION=21.8.0 -t asterisk:21.8 ./asterisk/ # Build with Asterisk 20 LTS instead
docker build --build-arg ASTERISK_VERSION=20.11.0 -t asterisk:20 ./asterisk/
# =============================================================================
# Asterisk Docker Stack - Environment Variables
# Copy this file to .env and customize for your deployment
# ============================================================================= # ---- General ----
COMPOSE_PROJECT_NAME=asterisk-stack
TZ=UTC # ---- Network / NAT ----
# Your server's public IP address (REQUIRED for SIP/RTP to work)
PJSIP_EXTERNAL_IP=YOUR_SERVER_IP
# Your domain name (for TLS certificates)
DOMAIN=YOUR_DOMAIN
# Contact email for Let's Encrypt
ACME_EMAIL=admin@YOUR_DOMAIN # ---- SIP Configuration ----
# Default SIP endpoint password (change this!)
SIP_DEFAULT_PASSWORD=Ch4ng3M3N0w!
# SIP realm for authentication
SIP_REALM=YOUR_DOMAIN # ---- RTP ----
RTP_PORT_START=10000
RTP_PORT_END=20000 # ---- Database ----
DB_HOST=mariadb
DB_PORT=3306
DB_NAME=asterisk
DB_USER=asterisk
DB_PASSWORD=Ast3r1sk_DB_2026!
DB_ROOT_PASSWORD=R00t_DB_S3cur3! # ---- Redis ----
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_PASSWORD=R3d1s_S3cur3_2026! # ---- ARI ----
ARI_USERNAME=ari_user
ARI_PASSWORD=Ar1_S3cur3_2026! # ---- AMI (Asterisk Manager Interface) ----
AMI_USERNAME=ami_admin
AMI_PASSWORD=Am1_S3cur3_2026! # ---- TURN Server ----
TURN_SECRET=T0rn_Sh4r3d_S3cr3t_2026!
TURN_REALM=YOUR_DOMAIN # ---- Nginx ----
NGINX_CLIENT_MAX_BODY=50M
# =============================================================================
# Asterisk Docker Stack - Environment Variables
# Copy this file to .env and customize for your deployment
# ============================================================================= # ---- General ----
COMPOSE_PROJECT_NAME=asterisk-stack
TZ=UTC # ---- Network / NAT ----
# Your server's public IP address (REQUIRED for SIP/RTP to work)
PJSIP_EXTERNAL_IP=YOUR_SERVER_IP
# Your domain name (for TLS certificates)
DOMAIN=YOUR_DOMAIN
# Contact email for Let's Encrypt
ACME_EMAIL=admin@YOUR_DOMAIN # ---- SIP Configuration ----
# Default SIP endpoint password (change this!)
SIP_DEFAULT_PASSWORD=Ch4ng3M3N0w!
# SIP realm for authentication
SIP_REALM=YOUR_DOMAIN # ---- RTP ----
RTP_PORT_START=10000
RTP_PORT_END=20000 # ---- Database ----
DB_HOST=mariadb
DB_PORT=3306
DB_NAME=asterisk
DB_USER=asterisk
DB_PASSWORD=Ast3r1sk_DB_2026!
DB_ROOT_PASSWORD=R00t_DB_S3cur3! # ---- Redis ----
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_PASSWORD=R3d1s_S3cur3_2026! # ---- ARI ----
ARI_USERNAME=ari_user
ARI_PASSWORD=Ar1_S3cur3_2026! # ---- AMI (Asterisk Manager Interface) ----
AMI_USERNAME=ami_admin
AMI_PASSWORD=Am1_S3cur3_2026! # ---- TURN Server ----
TURN_SECRET=T0rn_Sh4r3d_S3cr3t_2026!
TURN_REALM=YOUR_DOMAIN # ---- Nginx ----
NGINX_CLIENT_MAX_BODY=50M
# =============================================================================
# Asterisk Docker Stack - Environment Variables
# Copy this file to .env and customize for your deployment
# ============================================================================= # ---- General ----
COMPOSE_PROJECT_NAME=asterisk-stack
TZ=UTC # ---- Network / NAT ----
# Your server's public IP address (REQUIRED for SIP/RTP to work)
PJSIP_EXTERNAL_IP=YOUR_SERVER_IP
# Your domain name (for TLS certificates)
DOMAIN=YOUR_DOMAIN
# Contact email for Let's Encrypt
ACME_EMAIL=admin@YOUR_DOMAIN # ---- SIP Configuration ----
# Default SIP endpoint password (change this!)
SIP_DEFAULT_PASSWORD=Ch4ng3M3N0w!
# SIP realm for authentication
SIP_REALM=YOUR_DOMAIN # ---- RTP ----
RTP_PORT_START=10000
RTP_PORT_END=20000 # ---- Database ----
DB_HOST=mariadb
DB_PORT=3306
DB_NAME=asterisk
DB_USER=asterisk
DB_PASSWORD=Ast3r1sk_DB_2026!
DB_ROOT_PASSWORD=R00t_DB_S3cur3! # ---- Redis ----
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_PASSWORD=R3d1s_S3cur3_2026! # ---- ARI ----
ARI_USERNAME=ari_user
ARI_PASSWORD=Ar1_S3cur3_2026! # ---- AMI (Asterisk Manager Interface) ----
AMI_USERNAME=ami_admin
AMI_PASSWORD=Am1_S3cur3_2026! # ---- TURN Server ----
TURN_SECRET=T0rn_Sh4r3d_S3cr3t_2026!
TURN_REALM=YOUR_DOMAIN # ---- Nginx ----
NGINX_CLIENT_MAX_BODY=50M
# =============================================================================
# Asterisk Production Stack - Docker Compose
# =============================================================================
# Usage:
# docker compose up -d # Start all services
# docker compose logs -f # Follow all logs
# docker compose exec asterisk asterisk -rvvv # Asterisk CLI
# docker compose down # Stop all services
# ============================================================================= services: # --------------------------------------------------------------------------- # Asterisk PBX # --------------------------------------------------------------------------- asterisk: build: context: ./asterisk dockerfile: Dockerfile container_name: asterisk restart: unless-stopped # For production with real SIP trunks, host networking is often simplest. # See Section 7 for detailed discussion of networking modes. # Option A: Bridge networking (more isolated, more complex NAT config) networks: - asterisk-net ports: # SIP signaling - "5060:5060/udp" - "5060:5060/tcp" - "5061:5061/tcp" # AMI (restrict to localhost in production) - "127.0.0.1:5038:5038/tcp" # ARI HTTP (proxied through Nginx, but expose for internal access) - "127.0.0.1:8088:8088/tcp" # RTP media - this is a LARGE range # See Section 7 for why this is necessary and how to minimize it - "10000-20000:10000-20000/udp" # Option B: Host networking (uncomment below, comment out ports above) # network_mode: host environment: - TZ=${TZ:-UTC} - PJSIP_EXTERNAL_IP=${PJSIP_EXTERNAL_IP} - DOMAIN=${DOMAIN} - SIP_DEFAULT_PASSWORD=${SIP_DEFAULT_PASSWORD} - SIP_REALM=${SIP_REALM:-${DOMAIN}} - RTP_PORT_START=${RTP_PORT_START:-10000} - RTP_PORT_END=${RTP_PORT_END:-20000} - DB_HOST=${DB_HOST:-mariadb} - DB_PORT=${DB_PORT:-3306} - DB_NAME=${DB_NAME:-asterisk} - DB_USER=${DB_USER:-asterisk} - DB_PASSWORD=${DB_PASSWORD} - REDIS_HOST=${REDIS_HOST:-redis} - REDIS_PORT=${REDIS_PORT:-6379} - ARI_USERNAME=${ARI_USERNAME:-ari_user} - ARI_PASSWORD=${ARI_PASSWORD} - AMI_USERNAME=${AMI_USERNAME:-ami_admin} - AMI_PASSWORD=${AMI_PASSWORD} volumes: # Configuration templates (processed by entrypoint) - ./asterisk/configs:/etc/asterisk/templates:ro # Static configs (not templated) - ./asterisk/configs/modules.conf:/etc/asterisk/modules.conf:ro - ./asterisk/configs/rtp.conf:/etc/asterisk/rtp.conf:ro - ./asterisk/configs/cdr.conf:/etc/asterisk/cdr.conf:ro # ODBC configuration template - ./asterisk/configs/odbc.ini.template:/etc/odbc.ini.template:ro # Persistent data - recordings:/var/spool/asterisk/monitor - voicemail:/var/spool/asterisk/voicemail - asterisk-logs:/var/log/asterisk # Custom sounds - ./asterisk/sounds/custom:/var/lib/asterisk/sounds/custom:ro # TLS certificates (shared with Nginx/Certbot) - certs:/etc/asterisk/certs:ro depends_on: mariadb: condition: service_healthy redis: condition: service_healthy ulimits: nofile: soft: 65536 hard: 65536 core: soft: -1 hard: -1 deploy: resources: limits: cpus: '4' memory: 4G reservations: cpus: '1' memory: 512M healthcheck: test: ["CMD", "asterisk", "-rx", "core show version"] interval: 30s timeout: 5s start_period: 15s retries: 3 logging: driver: json-file options: max-size: "50m" max-file: "5" # --------------------------------------------------------------------------- # MariaDB - CDR, Realtime Config, Voicemail # --------------------------------------------------------------------------- mariadb: image: mariadb:11.4 container_name: asterisk-mariadb restart: unless-stopped networks: - asterisk-net # Do NOT expose port externally in production # Uncomment only for debugging: # ports: # - "127.0.0.1:3306:3306/tcp" environment: - MARIADB_ROOT_PASSWORD=${DB_ROOT_PASSWORD} - MARIADB_DATABASE=${DB_NAME:-asterisk} - MARIADB_USER=${DB_USER:-asterisk} - MARIADB_PASSWORD=${DB_PASSWORD} - TZ=${TZ:-UTC} volumes: - mariadb-data:/var/lib/mysql - ./mariadb/init:/docker-entrypoint-initdb.d:ro command: > --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci --max-connections=200 --innodb-buffer-pool-size=256M --innodb-log-file-size=64M --max-allowed-packet=64M --slow-query-log=1 --slow-query-log-file=/var/lib/mysql/slow.log --long-query-time=2 deploy: resources: limits: cpus: '2' memory: 1G reservations: memory: 256M healthcheck: test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] interval: 15s timeout: 5s start_period: 30s retries: 5 logging: driver: json-file options: max-size: "20m" max-file: "3" # --------------------------------------------------------------------------- # Redis - ARI State, Session Cache # --------------------------------------------------------------------------- redis: image: redis:7-alpine container_name: asterisk-redis restart: unless-stopped networks: - asterisk-net command: > redis-server --requirepass ${REDIS_PASSWORD} --maxmemory 128mb --maxmemory-policy allkeys-lru --appendonly yes --appendfsync everysec volumes: - redis-data:/data deploy: resources: limits: cpus: '0.5' memory: 256M healthcheck: test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"] interval: 15s timeout: 3s retries: 3 logging: driver: json-file options: max-size: "10m" max-file: "3" # --------------------------------------------------------------------------- # Nginx - Reverse Proxy, TLS Termination, WebSocket Proxy # --------------------------------------------------------------------------- nginx: image: nginx:1.27-alpine container_name: asterisk-nginx restart: unless-stopped networks: - asterisk-net ports: - "80:80/tcp" - "443:443/tcp" environment: - DOMAIN=${DOMAIN} volumes: - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro - ./nginx/conf.d:/etc/nginx/conf.d:ro - certs:/etc/nginx/certs:ro - certbot-webroot:/var/www/certbot:ro depends_on: - asterisk deploy: resources: limits: cpus: '1' memory: 256M healthcheck: test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost/health"] interval: 30s timeout: 5s retries: 3 logging: driver: json-file options: max-size: "20m" max-file: "3" # --------------------------------------------------------------------------- # Certbot - Let's Encrypt Certificate Management # --------------------------------------------------------------------------- certbot: image: certbot/certbot:latest container_name: asterisk-certbot restart: unless-stopped volumes: - certs:/etc/letsencrypt - certbot-webroot:/var/www/certbot # Check for renewal twice daily (standard recommendation) entrypoint: /bin/sh -c "trap exit TERM; while :; do certbot renew --quiet; sleep 12h & wait $${!}; done" depends_on: - nginx # --------------------------------------------------------------------------- # Coturn - TURN/STUN Server for WebRTC NAT Traversal # --------------------------------------------------------------------------- coturn: image: coturn/coturn:latest container_name: asterisk-coturn restart: unless-stopped network_mode: host volumes: - ./coturn/turnserver.conf:/etc/turnserver.conf:ro - certs:/etc/certs:ro command: ["-c", "/etc/turnserver.conf"] deploy: resources: limits: cpus: '1' memory: 512M logging: driver: json-file options: max-size: "20m" max-file: "3" # =============================================================================
# Networks
# =============================================================================
networks: asterisk-net: driver: bridge ipam: config: - subnet: 172.25.0.0/24 # =============================================================================
# Volumes
# =============================================================================
volumes: mariadb-data: driver: local redis-data: driver: local recordings: driver: local driver_opts: type: none device: /opt/asterisk-docker/data/recordings o: bind voicemail: driver: local driver_opts: type: none device: /opt/asterisk-docker/data/voicemail o: bind asterisk-logs: driver: local driver_opts: type: none device: /opt/asterisk-docker/data/logs o: bind certs: driver: local driver_opts: type: none device: /opt/asterisk-docker/data/certs o: bind certbot-webroot: driver: local
# =============================================================================
# Asterisk Production Stack - Docker Compose
# =============================================================================
# Usage:
# docker compose up -d # Start all services
# docker compose logs -f # Follow all logs
# docker compose exec asterisk asterisk -rvvv # Asterisk CLI
# docker compose down # Stop all services
# ============================================================================= services: # --------------------------------------------------------------------------- # Asterisk PBX # --------------------------------------------------------------------------- asterisk: build: context: ./asterisk dockerfile: Dockerfile container_name: asterisk restart: unless-stopped # For production with real SIP trunks, host networking is often simplest. # See Section 7 for detailed discussion of networking modes. # Option A: Bridge networking (more isolated, more complex NAT config) networks: - asterisk-net ports: # SIP signaling - "5060:5060/udp" - "5060:5060/tcp" - "5061:5061/tcp" # AMI (restrict to localhost in production) - "127.0.0.1:5038:5038/tcp" # ARI HTTP (proxied through Nginx, but expose for internal access) - "127.0.0.1:8088:8088/tcp" # RTP media - this is a LARGE range # See Section 7 for why this is necessary and how to minimize it - "10000-20000:10000-20000/udp" # Option B: Host networking (uncomment below, comment out ports above) # network_mode: host environment: - TZ=${TZ:-UTC} - PJSIP_EXTERNAL_IP=${PJSIP_EXTERNAL_IP} - DOMAIN=${DOMAIN} - SIP_DEFAULT_PASSWORD=${SIP_DEFAULT_PASSWORD} - SIP_REALM=${SIP_REALM:-${DOMAIN}} - RTP_PORT_START=${RTP_PORT_START:-10000} - RTP_PORT_END=${RTP_PORT_END:-20000} - DB_HOST=${DB_HOST:-mariadb} - DB_PORT=${DB_PORT:-3306} - DB_NAME=${DB_NAME:-asterisk} - DB_USER=${DB_USER:-asterisk} - DB_PASSWORD=${DB_PASSWORD} - REDIS_HOST=${REDIS_HOST:-redis} - REDIS_PORT=${REDIS_PORT:-6379} - ARI_USERNAME=${ARI_USERNAME:-ari_user} - ARI_PASSWORD=${ARI_PASSWORD} - AMI_USERNAME=${AMI_USERNAME:-ami_admin} - AMI_PASSWORD=${AMI_PASSWORD} volumes: # Configuration templates (processed by entrypoint) - ./asterisk/configs:/etc/asterisk/templates:ro # Static configs (not templated) - ./asterisk/configs/modules.conf:/etc/asterisk/modules.conf:ro - ./asterisk/configs/rtp.conf:/etc/asterisk/rtp.conf:ro - ./asterisk/configs/cdr.conf:/etc/asterisk/cdr.conf:ro # ODBC configuration template - ./asterisk/configs/odbc.ini.template:/etc/odbc.ini.template:ro # Persistent data - recordings:/var/spool/asterisk/monitor - voicemail:/var/spool/asterisk/voicemail - asterisk-logs:/var/log/asterisk # Custom sounds - ./asterisk/sounds/custom:/var/lib/asterisk/sounds/custom:ro # TLS certificates (shared with Nginx/Certbot) - certs:/etc/asterisk/certs:ro depends_on: mariadb: condition: service_healthy redis: condition: service_healthy ulimits: nofile: soft: 65536 hard: 65536 core: soft: -1 hard: -1 deploy: resources: limits: cpus: '4' memory: 4G reservations: cpus: '1' memory: 512M healthcheck: test: ["CMD", "asterisk", "-rx", "core show version"] interval: 30s timeout: 5s start_period: 15s retries: 3 logging: driver: json-file options: max-size: "50m" max-file: "5" # --------------------------------------------------------------------------- # MariaDB - CDR, Realtime Config, Voicemail # --------------------------------------------------------------------------- mariadb: image: mariadb:11.4 container_name: asterisk-mariadb restart: unless-stopped networks: - asterisk-net # Do NOT expose port externally in production # Uncomment only for debugging: # ports: # - "127.0.0.1:3306:3306/tcp" environment: - MARIADB_ROOT_PASSWORD=${DB_ROOT_PASSWORD} - MARIADB_DATABASE=${DB_NAME:-asterisk} - MARIADB_USER=${DB_USER:-asterisk} - MARIADB_PASSWORD=${DB_PASSWORD} - TZ=${TZ:-UTC} volumes: - mariadb-data:/var/lib/mysql - ./mariadb/init:/docker-entrypoint-initdb.d:ro command: > --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci --max-connections=200 --innodb-buffer-pool-size=256M --innodb-log-file-size=64M --max-allowed-packet=64M --slow-query-log=1 --slow-query-log-file=/var/lib/mysql/slow.log --long-query-time=2 deploy: resources: limits: cpus: '2' memory: 1G reservations: memory: 256M healthcheck: test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] interval: 15s timeout: 5s start_period: 30s retries: 5 logging: driver: json-file options: max-size: "20m" max-file: "3" # --------------------------------------------------------------------------- # Redis - ARI State, Session Cache # --------------------------------------------------------------------------- redis: image: redis:7-alpine container_name: asterisk-redis restart: unless-stopped networks: - asterisk-net command: > redis-server --requirepass ${REDIS_PASSWORD} --maxmemory 128mb --maxmemory-policy allkeys-lru --appendonly yes --appendfsync everysec volumes: - redis-data:/data deploy: resources: limits: cpus: '0.5' memory: 256M healthcheck: test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"] interval: 15s timeout: 3s retries: 3 logging: driver: json-file options: max-size: "10m" max-file: "3" # --------------------------------------------------------------------------- # Nginx - Reverse Proxy, TLS Termination, WebSocket Proxy # --------------------------------------------------------------------------- nginx: image: nginx:1.27-alpine container_name: asterisk-nginx restart: unless-stopped networks: - asterisk-net ports: - "80:80/tcp" - "443:443/tcp" environment: - DOMAIN=${DOMAIN} volumes: - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro - ./nginx/conf.d:/etc/nginx/conf.d:ro - certs:/etc/nginx/certs:ro - certbot-webroot:/var/www/certbot:ro depends_on: - asterisk deploy: resources: limits: cpus: '1' memory: 256M healthcheck: test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost/health"] interval: 30s timeout: 5s retries: 3 logging: driver: json-file options: max-size: "20m" max-file: "3" # --------------------------------------------------------------------------- # Certbot - Let's Encrypt Certificate Management # --------------------------------------------------------------------------- certbot: image: certbot/certbot:latest container_name: asterisk-certbot restart: unless-stopped volumes: - certs:/etc/letsencrypt - certbot-webroot:/var/www/certbot # Check for renewal twice daily (standard recommendation) entrypoint: /bin/sh -c "trap exit TERM; while :; do certbot renew --quiet; sleep 12h & wait $${!}; done" depends_on: - nginx # --------------------------------------------------------------------------- # Coturn - TURN/STUN Server for WebRTC NAT Traversal # --------------------------------------------------------------------------- coturn: image: coturn/coturn:latest container_name: asterisk-coturn restart: unless-stopped network_mode: host volumes: - ./coturn/turnserver.conf:/etc/turnserver.conf:ro - certs:/etc/certs:ro command: ["-c", "/etc/turnserver.conf"] deploy: resources: limits: cpus: '1' memory: 512M logging: driver: json-file options: max-size: "20m" max-file: "3" # =============================================================================
# Networks
# =============================================================================
networks: asterisk-net: driver: bridge ipam: config: - subnet: 172.25.0.0/24 # =============================================================================
# Volumes
# =============================================================================
volumes: mariadb-data: driver: local redis-data: driver: local recordings: driver: local driver_opts: type: none device: /opt/asterisk-docker/data/recordings o: bind voicemail: driver: local driver_opts: type: none device: /opt/asterisk-docker/data/voicemail o: bind asterisk-logs: driver: local driver_opts: type: none device: /opt/asterisk-docker/data/logs o: bind certs: driver: local driver_opts: type: none device: /opt/asterisk-docker/data/certs o: bind certbot-webroot: driver: local
# =============================================================================
# Asterisk Production Stack - Docker Compose
# =============================================================================
# Usage:
# docker compose up -d # Start all services
# docker compose logs -f # Follow all logs
# docker compose exec asterisk asterisk -rvvv # Asterisk CLI
# docker compose down # Stop all services
# ============================================================================= services: # --------------------------------------------------------------------------- # Asterisk PBX # --------------------------------------------------------------------------- asterisk: build: context: ./asterisk dockerfile: Dockerfile container_name: asterisk restart: unless-stopped # For production with real SIP trunks, host networking is often simplest. # See Section 7 for detailed discussion of networking modes. # Option A: Bridge networking (more isolated, more complex NAT config) networks: - asterisk-net ports: # SIP signaling - "5060:5060/udp" - "5060:5060/tcp" - "5061:5061/tcp" # AMI (restrict to localhost in production) - "127.0.0.1:5038:5038/tcp" # ARI HTTP (proxied through Nginx, but expose for internal access) - "127.0.0.1:8088:8088/tcp" # RTP media - this is a LARGE range # See Section 7 for why this is necessary and how to minimize it - "10000-20000:10000-20000/udp" # Option B: Host networking (uncomment below, comment out ports above) # network_mode: host environment: - TZ=${TZ:-UTC} - PJSIP_EXTERNAL_IP=${PJSIP_EXTERNAL_IP} - DOMAIN=${DOMAIN} - SIP_DEFAULT_PASSWORD=${SIP_DEFAULT_PASSWORD} - SIP_REALM=${SIP_REALM:-${DOMAIN}} - RTP_PORT_START=${RTP_PORT_START:-10000} - RTP_PORT_END=${RTP_PORT_END:-20000} - DB_HOST=${DB_HOST:-mariadb} - DB_PORT=${DB_PORT:-3306} - DB_NAME=${DB_NAME:-asterisk} - DB_USER=${DB_USER:-asterisk} - DB_PASSWORD=${DB_PASSWORD} - REDIS_HOST=${REDIS_HOST:-redis} - REDIS_PORT=${REDIS_PORT:-6379} - ARI_USERNAME=${ARI_USERNAME:-ari_user} - ARI_PASSWORD=${ARI_PASSWORD} - AMI_USERNAME=${AMI_USERNAME:-ami_admin} - AMI_PASSWORD=${AMI_PASSWORD} volumes: # Configuration templates (processed by entrypoint) - ./asterisk/configs:/etc/asterisk/templates:ro # Static configs (not templated) - ./asterisk/configs/modules.conf:/etc/asterisk/modules.conf:ro - ./asterisk/configs/rtp.conf:/etc/asterisk/rtp.conf:ro - ./asterisk/configs/cdr.conf:/etc/asterisk/cdr.conf:ro # ODBC configuration template - ./asterisk/configs/odbc.ini.template:/etc/odbc.ini.template:ro # Persistent data - recordings:/var/spool/asterisk/monitor - voicemail:/var/spool/asterisk/voicemail - asterisk-logs:/var/log/asterisk # Custom sounds - ./asterisk/sounds/custom:/var/lib/asterisk/sounds/custom:ro # TLS certificates (shared with Nginx/Certbot) - certs:/etc/asterisk/certs:ro depends_on: mariadb: condition: service_healthy redis: condition: service_healthy ulimits: nofile: soft: 65536 hard: 65536 core: soft: -1 hard: -1 deploy: resources: limits: cpus: '4' memory: 4G reservations: cpus: '1' memory: 512M healthcheck: test: ["CMD", "asterisk", "-rx", "core show version"] interval: 30s timeout: 5s start_period: 15s retries: 3 logging: driver: json-file options: max-size: "50m" max-file: "5" # --------------------------------------------------------------------------- # MariaDB - CDR, Realtime Config, Voicemail # --------------------------------------------------------------------------- mariadb: image: mariadb:11.4 container_name: asterisk-mariadb restart: unless-stopped networks: - asterisk-net # Do NOT expose port externally in production # Uncomment only for debugging: # ports: # - "127.0.0.1:3306:3306/tcp" environment: - MARIADB_ROOT_PASSWORD=${DB_ROOT_PASSWORD} - MARIADB_DATABASE=${DB_NAME:-asterisk} - MARIADB_USER=${DB_USER:-asterisk} - MARIADB_PASSWORD=${DB_PASSWORD} - TZ=${TZ:-UTC} volumes: - mariadb-data:/var/lib/mysql - ./mariadb/init:/docker-entrypoint-initdb.d:ro command: > --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci --max-connections=200 --innodb-buffer-pool-size=256M --innodb-log-file-size=64M --max-allowed-packet=64M --slow-query-log=1 --slow-query-log-file=/var/lib/mysql/slow.log --long-query-time=2 deploy: resources: limits: cpus: '2' memory: 1G reservations: memory: 256M healthcheck: test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] interval: 15s timeout: 5s start_period: 30s retries: 5 logging: driver: json-file options: max-size: "20m" max-file: "3" # --------------------------------------------------------------------------- # Redis - ARI State, Session Cache # --------------------------------------------------------------------------- redis: image: redis:7-alpine container_name: asterisk-redis restart: unless-stopped networks: - asterisk-net command: > redis-server --requirepass ${REDIS_PASSWORD} --maxmemory 128mb --maxmemory-policy allkeys-lru --appendonly yes --appendfsync everysec volumes: - redis-data:/data deploy: resources: limits: cpus: '0.5' memory: 256M healthcheck: test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"] interval: 15s timeout: 3s retries: 3 logging: driver: json-file options: max-size: "10m" max-file: "3" # --------------------------------------------------------------------------- # Nginx - Reverse Proxy, TLS Termination, WebSocket Proxy # --------------------------------------------------------------------------- nginx: image: nginx:1.27-alpine container_name: asterisk-nginx restart: unless-stopped networks: - asterisk-net ports: - "80:80/tcp" - "443:443/tcp" environment: - DOMAIN=${DOMAIN} volumes: - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro - ./nginx/conf.d:/etc/nginx/conf.d:ro - certs:/etc/nginx/certs:ro - certbot-webroot:/var/www/certbot:ro depends_on: - asterisk deploy: resources: limits: cpus: '1' memory: 256M healthcheck: test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost/health"] interval: 30s timeout: 5s retries: 3 logging: driver: json-file options: max-size: "20m" max-file: "3" # --------------------------------------------------------------------------- # Certbot - Let's Encrypt Certificate Management # --------------------------------------------------------------------------- certbot: image: certbot/certbot:latest container_name: asterisk-certbot restart: unless-stopped volumes: - certs:/etc/letsencrypt - certbot-webroot:/var/www/certbot # Check for renewal twice daily (standard recommendation) entrypoint: /bin/sh -c "trap exit TERM; while :; do certbot renew --quiet; sleep 12h & wait $${!}; done" depends_on: - nginx # --------------------------------------------------------------------------- # Coturn - TURN/STUN Server for WebRTC NAT Traversal # --------------------------------------------------------------------------- coturn: image: coturn/coturn:latest container_name: asterisk-coturn restart: unless-stopped network_mode: host volumes: - ./coturn/turnserver.conf:/etc/turnserver.conf:ro - certs:/etc/certs:ro command: ["-c", "/etc/turnserver.conf"] deploy: resources: limits: cpus: '1' memory: 512M logging: driver: json-file options: max-size: "20m" max-file: "3" # =============================================================================
# Networks
# =============================================================================
networks: asterisk-net: driver: bridge ipam: config: - subnet: 172.25.0.0/24 # =============================================================================
# Volumes
# =============================================================================
volumes: mariadb-data: driver: local redis-data: driver: local recordings: driver: local driver_opts: type: none device: /opt/asterisk-docker/data/recordings o: bind voicemail: driver: local driver_opts: type: none device: /opt/asterisk-docker/data/voicemail o: bind asterisk-logs: driver: local driver_opts: type: none device: /opt/asterisk-docker/data/logs o: bind certs: driver: local driver_opts: type: none device: /opt/asterisk-docker/data/certs o: bind certbot-webroot: driver: local
cd /opt/asterisk-docker # Create bind-mount directories
mkdir -p data/{recordings,voicemail,logs,certs} # Build and start everything
docker compose up -d --build # Watch logs during startup
docker compose logs -f asterisk # Verify all containers are healthy
docker compose ps # Access Asterisk CLI
docker compose exec asterisk asterisk -rvvv
cd /opt/asterisk-docker # Create bind-mount directories
mkdir -p data/{recordings,voicemail,logs,certs} # Build and start everything
docker compose up -d --build # Watch logs during startup
docker compose logs -f asterisk # Verify all containers are healthy
docker compose ps # Access Asterisk CLI
docker compose exec asterisk asterisk -rvvv
cd /opt/asterisk-docker # Create bind-mount directories
mkdir -p data/{recordings,voicemail,logs,certs} # Build and start everything
docker compose up -d --build # Watch logs during startup
docker compose logs -f asterisk # Verify all containers are healthy
docker compose ps # Access Asterisk CLI
docker compose exec asterisk asterisk -rvvv
; =============================================================================
; PJSIP Configuration - Containerized Asterisk
; Variables like ${VARIABLE} are replaced by entrypoint.sh at container start
; ============================================================================= ; ---- Global Settings ----
[global]
type = global
max_forwards = 70
user_agent = Asterisk PBX
default_outbound_endpoint = default-endpoint
keep_alive_interval = 90 ; ---- System Settings ----
[system]
type = system
timer_t1 = 500
timer_b = 32000
compact_headers = no ; =============================================================================
; Transports
; ============================================================================= ; ---- UDP Transport ----
[transport-udp]
type = transport
protocol = udp
bind = 0.0.0.0:5060
; NAT settings - critical for containerized Asterisk
external_media_address = ${PJSIP_EXTERNAL_IP}
external_signaling_address = ${PJSIP_EXTERNAL_IP}
local_net = 172.25.0.0/24
local_net = 10.0.0.0/8
local_net = 172.16.0.0/12
local_net = 192.168.0.0/16
local_net = 127.0.0.0/8 ; ---- TCP Transport ----
[transport-tcp]
type = transport
protocol = tcp
bind = 0.0.0.0:5060
external_media_address = ${PJSIP_EXTERNAL_IP}
external_signaling_address = ${PJSIP_EXTERNAL_IP}
local_net = 172.25.0.0/24
local_net = 10.0.0.0/8
local_net = 172.16.0.0/12
local_net = 192.168.0.0/16
local_net = 127.0.0.0/8 ; ---- TLS Transport ----
[transport-tls]
type = transport
protocol = tls
bind = 0.0.0.0:5061
external_media_address = ${PJSIP_EXTERNAL_IP}
external_signaling_address = ${PJSIP_EXTERNAL_IP}
local_net = 172.25.0.0/24
local_net = 10.0.0.0/8
local_net = 172.16.0.0/12
local_net = 192.168.0.0/16
local_net = 127.0.0.0/8
cert_file = /etc/asterisk/certs/live/${DOMAIN}/fullchain.pem
priv_key_file = /etc/asterisk/certs/live/${DOMAIN}/privkey.pem
method = tlsv1_2
; cipher = ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256 ; ---- WebSocket Transport (for WebRTC) ----
[transport-wss]
type = transport
protocol = wss
bind = 0.0.0.0:8089
external_media_address = ${PJSIP_EXTERNAL_IP}
external_signaling_address = ${PJSIP_EXTERNAL_IP}
local_net = 172.25.0.0/24
local_net = 10.0.0.0/8
local_net = 172.16.0.0/12
local_net = 192.168.0.0/16
local_net = 127.0.0.0/8
cert_file = /etc/asterisk/certs/live/${DOMAIN}/fullchain.pem
priv_key_file = /etc/asterisk/certs/live/${DOMAIN}/privkey.pem ; =============================================================================
; Endpoint Templates
; ============================================================================= ; ---- Template for standard SIP phones ----
[endpoint-internal](!)
type = endpoint
context = from-internal
dtmf_mode = rfc4733
disallow = all
allow = opus,g722,ulaw,alaw
direct_media = no
rtp_symmetric = yes
force_rport = yes
rewrite_contact = yes
ice_support = no
media_encryption = no
trust_id_inbound = yes
send_rpid = yes
send_pai = yes
callerid_privacy = allowed_not_screened
; One-way audio fix for NAT
media_address = ${PJSIP_EXTERNAL_IP} ; ---- Template for WebRTC endpoints ----
[endpoint-webrtc](!)
type = endpoint
context = from-internal
dtmf_mode = rfc4733
disallow = all
allow = opus,ulaw
direct_media = no
rtp_symmetric = yes
force_rport = yes
rewrite_contact = yes
ice_support = yes
media_encryption = dtls
dtls_auto_generate_cert = yes
dtls_verify = fingerprint
dtls_setup = actpass
media_use_received_transport = yes
trust_id_inbound = yes
webrtc = yes ; ---- Template for SIP trunks ----
[endpoint-trunk](!)
type = endpoint
context = from-trunk
dtmf_mode = rfc4733
disallow = all
allow = g722,ulaw,alaw
direct_media = no
rtp_symmetric = yes
force_rport = yes
rewrite_contact = yes
send_rpid = yes
send_pai = yes
media_address = ${PJSIP_EXTERNAL_IP}
t38_udptl = yes
t38_udptl_ec = redundancy ; =============================================================================
; Sample Endpoints
; ============================================================================= ; ---- Extension 1001 (SIP Phone) ----
[1001](endpoint-internal)
auth = 1001-auth
aors = 1001-aor
callerid = "Reception" <1001>
mailboxes = 1001@default
call_group = 1
pickup_group = 1 [1001-auth]
type = auth
auth_type = userpass
username = 1001
password = ${SIP_DEFAULT_PASSWORD}
realm = ${SIP_REALM} [1001-aor]
type = aor
max_contacts = 3
remove_existing = yes
qualify_frequency = 60
qualify_timeout = 5
minimum_expiration = 120
default_expiration = 300 ; ---- Extension 1002 (SIP Phone) ----
[1002](endpoint-internal)
auth = 1002-auth
aors = 1002-aor
callerid = "Sales" <1002>
mailboxes = 1002@default
call_group = 1
pickup_group = 1 [1002-auth]
type = auth
auth_type = userpass
username = 1002
password = ${SIP_DEFAULT_PASSWORD}
realm = ${SIP_REALM} [1002-aor]
type = aor
max_contacts = 3
remove_existing = yes
qualify_frequency = 60
qualify_timeout = 5 ; ---- Extension 1010 (WebRTC Softphone) ----
[1010](endpoint-webrtc)
auth = 1010-auth
aors = 1010-aor
callerid = "Web Phone" <1010>
mailboxes = 1010@default [1010-auth]
type = auth
auth_type = userpass
username = 1010
password = ${SIP_DEFAULT_PASSWORD}
realm = ${SIP_REALM} [1010-aor]
type = aor
max_contacts = 5
remove_existing = yes
qualify_frequency = 30 ; ---- Sample SIP Trunk ----
; Uncomment and configure for your SIP provider
; [my-trunk](endpoint-trunk)
; outbound_auth = my-trunk-auth
; aors = my-trunk-aor
; from_domain = sip.provider.example.com
; from_user = your_account_id
;
; [my-trunk-auth]
; type = auth
; auth_type = userpass
; username = your_account_id
; password = your_trunk_password
;
; [my-trunk-aor]
; type = aor
; contact = sip:sip.provider.example.com
; qualify_frequency = 60
;
; [my-trunk-identify]
; type = identify
; endpoint = my-trunk
; match = sip.provider.example.com ; ---- Default Endpoint (catch-all for unmatched) ----
[default-endpoint]
type = endpoint
context = default
disallow = all
allow = ulaw
transport = transport-udp
; =============================================================================
; PJSIP Configuration - Containerized Asterisk
; Variables like ${VARIABLE} are replaced by entrypoint.sh at container start
; ============================================================================= ; ---- Global Settings ----
[global]
type = global
max_forwards = 70
user_agent = Asterisk PBX
default_outbound_endpoint = default-endpoint
keep_alive_interval = 90 ; ---- System Settings ----
[system]
type = system
timer_t1 = 500
timer_b = 32000
compact_headers = no ; =============================================================================
; Transports
; ============================================================================= ; ---- UDP Transport ----
[transport-udp]
type = transport
protocol = udp
bind = 0.0.0.0:5060
; NAT settings - critical for containerized Asterisk
external_media_address = ${PJSIP_EXTERNAL_IP}
external_signaling_address = ${PJSIP_EXTERNAL_IP}
local_net = 172.25.0.0/24
local_net = 10.0.0.0/8
local_net = 172.16.0.0/12
local_net = 192.168.0.0/16
local_net = 127.0.0.0/8 ; ---- TCP Transport ----
[transport-tcp]
type = transport
protocol = tcp
bind = 0.0.0.0:5060
external_media_address = ${PJSIP_EXTERNAL_IP}
external_signaling_address = ${PJSIP_EXTERNAL_IP}
local_net = 172.25.0.0/24
local_net = 10.0.0.0/8
local_net = 172.16.0.0/12
local_net = 192.168.0.0/16
local_net = 127.0.0.0/8 ; ---- TLS Transport ----
[transport-tls]
type = transport
protocol = tls
bind = 0.0.0.0:5061
external_media_address = ${PJSIP_EXTERNAL_IP}
external_signaling_address = ${PJSIP_EXTERNAL_IP}
local_net = 172.25.0.0/24
local_net = 10.0.0.0/8
local_net = 172.16.0.0/12
local_net = 192.168.0.0/16
local_net = 127.0.0.0/8
cert_file = /etc/asterisk/certs/live/${DOMAIN}/fullchain.pem
priv_key_file = /etc/asterisk/certs/live/${DOMAIN}/privkey.pem
method = tlsv1_2
; cipher = ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256 ; ---- WebSocket Transport (for WebRTC) ----
[transport-wss]
type = transport
protocol = wss
bind = 0.0.0.0:8089
external_media_address = ${PJSIP_EXTERNAL_IP}
external_signaling_address = ${PJSIP_EXTERNAL_IP}
local_net = 172.25.0.0/24
local_net = 10.0.0.0/8
local_net = 172.16.0.0/12
local_net = 192.168.0.0/16
local_net = 127.0.0.0/8
cert_file = /etc/asterisk/certs/live/${DOMAIN}/fullchain.pem
priv_key_file = /etc/asterisk/certs/live/${DOMAIN}/privkey.pem ; =============================================================================
; Endpoint Templates
; ============================================================================= ; ---- Template for standard SIP phones ----
[endpoint-internal](!)
type = endpoint
context = from-internal
dtmf_mode = rfc4733
disallow = all
allow = opus,g722,ulaw,alaw
direct_media = no
rtp_symmetric = yes
force_rport = yes
rewrite_contact = yes
ice_support = no
media_encryption = no
trust_id_inbound = yes
send_rpid = yes
send_pai = yes
callerid_privacy = allowed_not_screened
; One-way audio fix for NAT
media_address = ${PJSIP_EXTERNAL_IP} ; ---- Template for WebRTC endpoints ----
[endpoint-webrtc](!)
type = endpoint
context = from-internal
dtmf_mode = rfc4733
disallow = all
allow = opus,ulaw
direct_media = no
rtp_symmetric = yes
force_rport = yes
rewrite_contact = yes
ice_support = yes
media_encryption = dtls
dtls_auto_generate_cert = yes
dtls_verify = fingerprint
dtls_setup = actpass
media_use_received_transport = yes
trust_id_inbound = yes
webrtc = yes ; ---- Template for SIP trunks ----
[endpoint-trunk](!)
type = endpoint
context = from-trunk
dtmf_mode = rfc4733
disallow = all
allow = g722,ulaw,alaw
direct_media = no
rtp_symmetric = yes
force_rport = yes
rewrite_contact = yes
send_rpid = yes
send_pai = yes
media_address = ${PJSIP_EXTERNAL_IP}
t38_udptl = yes
t38_udptl_ec = redundancy ; =============================================================================
; Sample Endpoints
; ============================================================================= ; ---- Extension 1001 (SIP Phone) ----
[1001](endpoint-internal)
auth = 1001-auth
aors = 1001-aor
callerid = "Reception" <1001>
mailboxes = 1001@default
call_group = 1
pickup_group = 1 [1001-auth]
type = auth
auth_type = userpass
username = 1001
password = ${SIP_DEFAULT_PASSWORD}
realm = ${SIP_REALM} [1001-aor]
type = aor
max_contacts = 3
remove_existing = yes
qualify_frequency = 60
qualify_timeout = 5
minimum_expiration = 120
default_expiration = 300 ; ---- Extension 1002 (SIP Phone) ----
[1002](endpoint-internal)
auth = 1002-auth
aors = 1002-aor
callerid = "Sales" <1002>
mailboxes = 1002@default
call_group = 1
pickup_group = 1 [1002-auth]
type = auth
auth_type = userpass
username = 1002
password = ${SIP_DEFAULT_PASSWORD}
realm = ${SIP_REALM} [1002-aor]
type = aor
max_contacts = 3
remove_existing = yes
qualify_frequency = 60
qualify_timeout = 5 ; ---- Extension 1010 (WebRTC Softphone) ----
[1010](endpoint-webrtc)
auth = 1010-auth
aors = 1010-aor
callerid = "Web Phone" <1010>
mailboxes = 1010@default [1010-auth]
type = auth
auth_type = userpass
username = 1010
password = ${SIP_DEFAULT_PASSWORD}
realm = ${SIP_REALM} [1010-aor]
type = aor
max_contacts = 5
remove_existing = yes
qualify_frequency = 30 ; ---- Sample SIP Trunk ----
; Uncomment and configure for your SIP provider
; [my-trunk](endpoint-trunk)
; outbound_auth = my-trunk-auth
; aors = my-trunk-aor
; from_domain = sip.provider.example.com
; from_user = your_account_id
;
; [my-trunk-auth]
; type = auth
; auth_type = userpass
; username = your_account_id
; password = your_trunk_password
;
; [my-trunk-aor]
; type = aor
; contact = sip:sip.provider.example.com
; qualify_frequency = 60
;
; [my-trunk-identify]
; type = identify
; endpoint = my-trunk
; match = sip.provider.example.com ; ---- Default Endpoint (catch-all for unmatched) ----
[default-endpoint]
type = endpoint
context = default
disallow = all
allow = ulaw
transport = transport-udp
; =============================================================================
; PJSIP Configuration - Containerized Asterisk
; Variables like ${VARIABLE} are replaced by entrypoint.sh at container start
; ============================================================================= ; ---- Global Settings ----
[global]
type = global
max_forwards = 70
user_agent = Asterisk PBX
default_outbound_endpoint = default-endpoint
keep_alive_interval = 90 ; ---- System Settings ----
[system]
type = system
timer_t1 = 500
timer_b = 32000
compact_headers = no ; =============================================================================
; Transports
; ============================================================================= ; ---- UDP Transport ----
[transport-udp]
type = transport
protocol = udp
bind = 0.0.0.0:5060
; NAT settings - critical for containerized Asterisk
external_media_address = ${PJSIP_EXTERNAL_IP}
external_signaling_address = ${PJSIP_EXTERNAL_IP}
local_net = 172.25.0.0/24
local_net = 10.0.0.0/8
local_net = 172.16.0.0/12
local_net = 192.168.0.0/16
local_net = 127.0.0.0/8 ; ---- TCP Transport ----
[transport-tcp]
type = transport
protocol = tcp
bind = 0.0.0.0:5060
external_media_address = ${PJSIP_EXTERNAL_IP}
external_signaling_address = ${PJSIP_EXTERNAL_IP}
local_net = 172.25.0.0/24
local_net = 10.0.0.0/8
local_net = 172.16.0.0/12
local_net = 192.168.0.0/16
local_net = 127.0.0.0/8 ; ---- TLS Transport ----
[transport-tls]
type = transport
protocol = tls
bind = 0.0.0.0:5061
external_media_address = ${PJSIP_EXTERNAL_IP}
external_signaling_address = ${PJSIP_EXTERNAL_IP}
local_net = 172.25.0.0/24
local_net = 10.0.0.0/8
local_net = 172.16.0.0/12
local_net = 192.168.0.0/16
local_net = 127.0.0.0/8
cert_file = /etc/asterisk/certs/live/${DOMAIN}/fullchain.pem
priv_key_file = /etc/asterisk/certs/live/${DOMAIN}/privkey.pem
method = tlsv1_2
; cipher = ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256 ; ---- WebSocket Transport (for WebRTC) ----
[transport-wss]
type = transport
protocol = wss
bind = 0.0.0.0:8089
external_media_address = ${PJSIP_EXTERNAL_IP}
external_signaling_address = ${PJSIP_EXTERNAL_IP}
local_net = 172.25.0.0/24
local_net = 10.0.0.0/8
local_net = 172.16.0.0/12
local_net = 192.168.0.0/16
local_net = 127.0.0.0/8
cert_file = /etc/asterisk/certs/live/${DOMAIN}/fullchain.pem
priv_key_file = /etc/asterisk/certs/live/${DOMAIN}/privkey.pem ; =============================================================================
; Endpoint Templates
; ============================================================================= ; ---- Template for standard SIP phones ----
[endpoint-internal](!)
type = endpoint
context = from-internal
dtmf_mode = rfc4733
disallow = all
allow = opus,g722,ulaw,alaw
direct_media = no
rtp_symmetric = yes
force_rport = yes
rewrite_contact = yes
ice_support = no
media_encryption = no
trust_id_inbound = yes
send_rpid = yes
send_pai = yes
callerid_privacy = allowed_not_screened
; One-way audio fix for NAT
media_address = ${PJSIP_EXTERNAL_IP} ; ---- Template for WebRTC endpoints ----
[endpoint-webrtc](!)
type = endpoint
context = from-internal
dtmf_mode = rfc4733
disallow = all
allow = opus,ulaw
direct_media = no
rtp_symmetric = yes
force_rport = yes
rewrite_contact = yes
ice_support = yes
media_encryption = dtls
dtls_auto_generate_cert = yes
dtls_verify = fingerprint
dtls_setup = actpass
media_use_received_transport = yes
trust_id_inbound = yes
webrtc = yes ; ---- Template for SIP trunks ----
[endpoint-trunk](!)
type = endpoint
context = from-trunk
dtmf_mode = rfc4733
disallow = all
allow = g722,ulaw,alaw
direct_media = no
rtp_symmetric = yes
force_rport = yes
rewrite_contact = yes
send_rpid = yes
send_pai = yes
media_address = ${PJSIP_EXTERNAL_IP}
t38_udptl = yes
t38_udptl_ec = redundancy ; =============================================================================
; Sample Endpoints
; ============================================================================= ; ---- Extension 1001 (SIP Phone) ----
[1001](endpoint-internal)
auth = 1001-auth
aors = 1001-aor
callerid = "Reception" <1001>
mailboxes = 1001@default
call_group = 1
pickup_group = 1 [1001-auth]
type = auth
auth_type = userpass
username = 1001
password = ${SIP_DEFAULT_PASSWORD}
realm = ${SIP_REALM} [1001-aor]
type = aor
max_contacts = 3
remove_existing = yes
qualify_frequency = 60
qualify_timeout = 5
minimum_expiration = 120
default_expiration = 300 ; ---- Extension 1002 (SIP Phone) ----
[1002](endpoint-internal)
auth = 1002-auth
aors = 1002-aor
callerid = "Sales" <1002>
mailboxes = 1002@default
call_group = 1
pickup_group = 1 [1002-auth]
type = auth
auth_type = userpass
username = 1002
password = ${SIP_DEFAULT_PASSWORD}
realm = ${SIP_REALM} [1002-aor]
type = aor
max_contacts = 3
remove_existing = yes
qualify_frequency = 60
qualify_timeout = 5 ; ---- Extension 1010 (WebRTC Softphone) ----
[1010](endpoint-webrtc)
auth = 1010-auth
aors = 1010-aor
callerid = "Web Phone" <1010>
mailboxes = 1010@default [1010-auth]
type = auth
auth_type = userpass
username = 1010
password = ${SIP_DEFAULT_PASSWORD}
realm = ${SIP_REALM} [1010-aor]
type = aor
max_contacts = 5
remove_existing = yes
qualify_frequency = 30 ; ---- Sample SIP Trunk ----
; Uncomment and configure for your SIP provider
; [my-trunk](endpoint-trunk)
; outbound_auth = my-trunk-auth
; aors = my-trunk-aor
; from_domain = sip.provider.example.com
; from_user = your_account_id
;
; [my-trunk-auth]
; type = auth
; auth_type = userpass
; username = your_account_id
; password = your_trunk_password
;
; [my-trunk-aor]
; type = aor
; contact = sip:sip.provider.example.com
; qualify_frequency = 60
;
; [my-trunk-identify]
; type = identify
; endpoint = my-trunk
; match = sip.provider.example.com ; ---- Default Endpoint (catch-all for unmatched) ----
[default-endpoint]
type = endpoint
context = default
disallow = all
allow = ulaw
transport = transport-udp
; =============================================================================
; Dialplan - Containerized Asterisk
; ============================================================================= [general]
static = yes
writeprotect = no
clearglobalvars = no [globals]
; Voicemail context
VM_CONTEXT = default
; Ring time before voicemail (seconds)
RING_TIMEOUT = 25
; Company name for auto-attendant
COMPANY_NAME = "Acme Corp"
; ARI application name
ARI_APP = autoattendant
; External caller ID for outbound calls
TRUNK_CID = "Main Line" <+15551234567> ; =============================================================================
; Internal Calls Context
; =============================================================================
[from-internal]
; ---- Direct extension dialing (1001-1099) ----
exten => _10XX,1,NoOp(Internal call to ${EXTEN}) same => n,Set(CALLERID(name)=${CALLERID(name)}) same => n,Dial(PJSIP/${EXTEN},${RING_TIMEOUT},tTkK) same => n,GotoIf($["${DIALSTATUS}" = "BUSY"]?busy:unavail) same => n(busy),VoiceMail(${EXTEN}@${VM_CONTEXT},b) same => n,Hangup() same => n(unavail),VoiceMail(${EXTEN}@${VM_CONTEXT},u) same => n,Hangup() ; ---- Ring group: Sales (ring all sales extensions) ----
exten => 2001,1,NoOp(Ring Group: Sales) same => n,Set(CALLERID(name)=${CALLERID(name)} [Sales]) same => n,Dial(PJSIP/1001&PJSIP/1002&PJSIP/1003,${RING_TIMEOUT},tTkK) same => n,VoiceMail(2001@${VM_CONTEXT},u) same => n,Hangup() ; ---- Ring group: Support ----
exten => 2002,1,NoOp(Ring Group: Support) same => n,Set(CALLERID(name)=${CALLERID(name)} [Support]) same => n,Dial(PJSIP/1004&PJSIP/1005&PJSIP/1006,${RING_TIMEOUT},tTkK) same => n,VoiceMail(2002@${VM_CONTEXT},u) same => n,Hangup() ; ---- Conference rooms (3001-3009) ----
exten => _300X,1,NoOp(Conference Room ${EXTEN}) same => n,Answer() same => n,ConfBridge(${EXTEN},default_bridge,default_user) same => n,Hangup() ; ---- Admin conference (3000 with admin profile) ----
exten => 3000,1,NoOp(Admin Conference) same => n,Answer() same => n,ConfBridge(3000,default_bridge,admin_user) same => n,Hangup() ; ---- Voicemail access ----
exten => *97,1,NoOp(Voicemail Access) same => n,VoiceMailMain(${CALLERID(num)}@${VM_CONTEXT}) same => n,Hangup() ; ---- Voicemail access (other mailbox) ----
exten => *98,1,NoOp(Voicemail Access - Other Mailbox) same => n,VoiceMailMain(@${VM_CONTEXT}) same => n,Hangup() ; ---- Echo test ----
exten => *43,1,NoOp(Echo Test) same => n,Answer() same => n,Playback(demo-echotest) same => n,Echo() same => n,Playback(demo-echodone) same => n,Hangup() ; ---- Speaking clock ----
exten => *60,1,NoOp(Speaking Clock) same => n,Answer() same => n,SayUnixTime(,,IMp) same => n,Hangup() ; ---- Attended transfer ----
exten => _*2.,1,NoOp(Attended Transfer to ${EXTEN:2}) same => n,Dial(PJSIP/${EXTEN:2},${RING_TIMEOUT},tTkK) same => n,Hangup() ; ---- Outbound calls via trunk ----
; Dial 9 + number for outbound
exten => _9.,1,NoOp(Outbound call to ${EXTEN:1}) same => n,Set(CALLERID(all)=${TRUNK_CID}) same => n,Dial(PJSIP/${EXTEN:1}@my-trunk,60,tTkK) same => n,Hangup() ; ---- ARI-controlled calls ----
; Prefix with 7 to route to ARI application
exten => _7.,1,NoOp(ARI Application for ${EXTEN:1}) same => n,Stasis(${ARI_APP},${EXTEN:1}) same => n,Hangup() ; ---- Invalid extension ----
exten => i,1,Playback(invalid) same => n,Hangup() ; =============================================================================
; Inbound Calls Context (from SIP trunks)
; =============================================================================
[from-trunk]
; ---- Main IVR ----
exten => _X.,1,NoOp(Inbound call from ${CALLERID(num)} to ${EXTEN}) same => n,Answer() same => n,Wait(1) same => n,Set(TIMEOUT(response)=10) same => n,Set(TIMEOUT(digit)=5) same => n(ivr),Background(custom/welcome) same => n,WaitExten(5) same => n,Goto(ivr-timeout,s,1) ; IVR option 1: Sales
exten => 1,1,NoOp(IVR: Sales selected) same => n,Playback(custom/transferring-sales) same => n,Goto(from-internal,2001,1) ; IVR option 2: Support
exten => 2,1,NoOp(IVR: Support selected) same => n,Playback(custom/transferring-support) same => n,Goto(from-internal,2002,1) ; IVR option 0: Operator
exten => 0,1,NoOp(IVR: Operator selected) same => n,Goto(from-internal,1001,1) ; IVR timeout or invalid
[ivr-timeout]
exten => s,1,NoOp(IVR timeout - routing to reception) same => n,Playback(custom/no-input) same => n,Goto(from-internal,1001,1) ; =============================================================================
; Default Context (catch-all, drop unknown traffic)
; =============================================================================
[default]
exten => _X.,1,NoOp(Dropping unrouted call to ${EXTEN} from ${CALLERID(num)}) same => n,Hangup(21) exten => _[a-z].,1,Hangup(21)
; =============================================================================
; Dialplan - Containerized Asterisk
; ============================================================================= [general]
static = yes
writeprotect = no
clearglobalvars = no [globals]
; Voicemail context
VM_CONTEXT = default
; Ring time before voicemail (seconds)
RING_TIMEOUT = 25
; Company name for auto-attendant
COMPANY_NAME = "Acme Corp"
; ARI application name
ARI_APP = autoattendant
; External caller ID for outbound calls
TRUNK_CID = "Main Line" <+15551234567> ; =============================================================================
; Internal Calls Context
; =============================================================================
[from-internal]
; ---- Direct extension dialing (1001-1099) ----
exten => _10XX,1,NoOp(Internal call to ${EXTEN}) same => n,Set(CALLERID(name)=${CALLERID(name)}) same => n,Dial(PJSIP/${EXTEN},${RING_TIMEOUT},tTkK) same => n,GotoIf($["${DIALSTATUS}" = "BUSY"]?busy:unavail) same => n(busy),VoiceMail(${EXTEN}@${VM_CONTEXT},b) same => n,Hangup() same => n(unavail),VoiceMail(${EXTEN}@${VM_CONTEXT},u) same => n,Hangup() ; ---- Ring group: Sales (ring all sales extensions) ----
exten => 2001,1,NoOp(Ring Group: Sales) same => n,Set(CALLERID(name)=${CALLERID(name)} [Sales]) same => n,Dial(PJSIP/1001&PJSIP/1002&PJSIP/1003,${RING_TIMEOUT},tTkK) same => n,VoiceMail(2001@${VM_CONTEXT},u) same => n,Hangup() ; ---- Ring group: Support ----
exten => 2002,1,NoOp(Ring Group: Support) same => n,Set(CALLERID(name)=${CALLERID(name)} [Support]) same => n,Dial(PJSIP/1004&PJSIP/1005&PJSIP/1006,${RING_TIMEOUT},tTkK) same => n,VoiceMail(2002@${VM_CONTEXT},u) same => n,Hangup() ; ---- Conference rooms (3001-3009) ----
exten => _300X,1,NoOp(Conference Room ${EXTEN}) same => n,Answer() same => n,ConfBridge(${EXTEN},default_bridge,default_user) same => n,Hangup() ; ---- Admin conference (3000 with admin profile) ----
exten => 3000,1,NoOp(Admin Conference) same => n,Answer() same => n,ConfBridge(3000,default_bridge,admin_user) same => n,Hangup() ; ---- Voicemail access ----
exten => *97,1,NoOp(Voicemail Access) same => n,VoiceMailMain(${CALLERID(num)}@${VM_CONTEXT}) same => n,Hangup() ; ---- Voicemail access (other mailbox) ----
exten => *98,1,NoOp(Voicemail Access - Other Mailbox) same => n,VoiceMailMain(@${VM_CONTEXT}) same => n,Hangup() ; ---- Echo test ----
exten => *43,1,NoOp(Echo Test) same => n,Answer() same => n,Playback(demo-echotest) same => n,Echo() same => n,Playback(demo-echodone) same => n,Hangup() ; ---- Speaking clock ----
exten => *60,1,NoOp(Speaking Clock) same => n,Answer() same => n,SayUnixTime(,,IMp) same => n,Hangup() ; ---- Attended transfer ----
exten => _*2.,1,NoOp(Attended Transfer to ${EXTEN:2}) same => n,Dial(PJSIP/${EXTEN:2},${RING_TIMEOUT},tTkK) same => n,Hangup() ; ---- Outbound calls via trunk ----
; Dial 9 + number for outbound
exten => _9.,1,NoOp(Outbound call to ${EXTEN:1}) same => n,Set(CALLERID(all)=${TRUNK_CID}) same => n,Dial(PJSIP/${EXTEN:1}@my-trunk,60,tTkK) same => n,Hangup() ; ---- ARI-controlled calls ----
; Prefix with 7 to route to ARI application
exten => _7.,1,NoOp(ARI Application for ${EXTEN:1}) same => n,Stasis(${ARI_APP},${EXTEN:1}) same => n,Hangup() ; ---- Invalid extension ----
exten => i,1,Playback(invalid) same => n,Hangup() ; =============================================================================
; Inbound Calls Context (from SIP trunks)
; =============================================================================
[from-trunk]
; ---- Main IVR ----
exten => _X.,1,NoOp(Inbound call from ${CALLERID(num)} to ${EXTEN}) same => n,Answer() same => n,Wait(1) same => n,Set(TIMEOUT(response)=10) same => n,Set(TIMEOUT(digit)=5) same => n(ivr),Background(custom/welcome) same => n,WaitExten(5) same => n,Goto(ivr-timeout,s,1) ; IVR option 1: Sales
exten => 1,1,NoOp(IVR: Sales selected) same => n,Playback(custom/transferring-sales) same => n,Goto(from-internal,2001,1) ; IVR option 2: Support
exten => 2,1,NoOp(IVR: Support selected) same => n,Playback(custom/transferring-support) same => n,Goto(from-internal,2002,1) ; IVR option 0: Operator
exten => 0,1,NoOp(IVR: Operator selected) same => n,Goto(from-internal,1001,1) ; IVR timeout or invalid
[ivr-timeout]
exten => s,1,NoOp(IVR timeout - routing to reception) same => n,Playback(custom/no-input) same => n,Goto(from-internal,1001,1) ; =============================================================================
; Default Context (catch-all, drop unknown traffic)
; =============================================================================
[default]
exten => _X.,1,NoOp(Dropping unrouted call to ${EXTEN} from ${CALLERID(num)}) same => n,Hangup(21) exten => _[a-z].,1,Hangup(21)
; =============================================================================
; Dialplan - Containerized Asterisk
; ============================================================================= [general]
static = yes
writeprotect = no
clearglobalvars = no [globals]
; Voicemail context
VM_CONTEXT = default
; Ring time before voicemail (seconds)
RING_TIMEOUT = 25
; Company name for auto-attendant
COMPANY_NAME = "Acme Corp"
; ARI application name
ARI_APP = autoattendant
; External caller ID for outbound calls
TRUNK_CID = "Main Line" <+15551234567> ; =============================================================================
; Internal Calls Context
; =============================================================================
[from-internal]
; ---- Direct extension dialing (1001-1099) ----
exten => _10XX,1,NoOp(Internal call to ${EXTEN}) same => n,Set(CALLERID(name)=${CALLERID(name)}) same => n,Dial(PJSIP/${EXTEN},${RING_TIMEOUT},tTkK) same => n,GotoIf($["${DIALSTATUS}" = "BUSY"]?busy:unavail) same => n(busy),VoiceMail(${EXTEN}@${VM_CONTEXT},b) same => n,Hangup() same => n(unavail),VoiceMail(${EXTEN}@${VM_CONTEXT},u) same => n,Hangup() ; ---- Ring group: Sales (ring all sales extensions) ----
exten => 2001,1,NoOp(Ring Group: Sales) same => n,Set(CALLERID(name)=${CALLERID(name)} [Sales]) same => n,Dial(PJSIP/1001&PJSIP/1002&PJSIP/1003,${RING_TIMEOUT},tTkK) same => n,VoiceMail(2001@${VM_CONTEXT},u) same => n,Hangup() ; ---- Ring group: Support ----
exten => 2002,1,NoOp(Ring Group: Support) same => n,Set(CALLERID(name)=${CALLERID(name)} [Support]) same => n,Dial(PJSIP/1004&PJSIP/1005&PJSIP/1006,${RING_TIMEOUT},tTkK) same => n,VoiceMail(2002@${VM_CONTEXT},u) same => n,Hangup() ; ---- Conference rooms (3001-3009) ----
exten => _300X,1,NoOp(Conference Room ${EXTEN}) same => n,Answer() same => n,ConfBridge(${EXTEN},default_bridge,default_user) same => n,Hangup() ; ---- Admin conference (3000 with admin profile) ----
exten => 3000,1,NoOp(Admin Conference) same => n,Answer() same => n,ConfBridge(3000,default_bridge,admin_user) same => n,Hangup() ; ---- Voicemail access ----
exten => *97,1,NoOp(Voicemail Access) same => n,VoiceMailMain(${CALLERID(num)}@${VM_CONTEXT}) same => n,Hangup() ; ---- Voicemail access (other mailbox) ----
exten => *98,1,NoOp(Voicemail Access - Other Mailbox) same => n,VoiceMailMain(@${VM_CONTEXT}) same => n,Hangup() ; ---- Echo test ----
exten => *43,1,NoOp(Echo Test) same => n,Answer() same => n,Playback(demo-echotest) same => n,Echo() same => n,Playback(demo-echodone) same => n,Hangup() ; ---- Speaking clock ----
exten => *60,1,NoOp(Speaking Clock) same => n,Answer() same => n,SayUnixTime(,,IMp) same => n,Hangup() ; ---- Attended transfer ----
exten => _*2.,1,NoOp(Attended Transfer to ${EXTEN:2}) same => n,Dial(PJSIP/${EXTEN:2},${RING_TIMEOUT},tTkK) same => n,Hangup() ; ---- Outbound calls via trunk ----
; Dial 9 + number for outbound
exten => _9.,1,NoOp(Outbound call to ${EXTEN:1}) same => n,Set(CALLERID(all)=${TRUNK_CID}) same => n,Dial(PJSIP/${EXTEN:1}@my-trunk,60,tTkK) same => n,Hangup() ; ---- ARI-controlled calls ----
; Prefix with 7 to route to ARI application
exten => _7.,1,NoOp(ARI Application for ${EXTEN:1}) same => n,Stasis(${ARI_APP},${EXTEN:1}) same => n,Hangup() ; ---- Invalid extension ----
exten => i,1,Playback(invalid) same => n,Hangup() ; =============================================================================
; Inbound Calls Context (from SIP trunks)
; =============================================================================
[from-trunk]
; ---- Main IVR ----
exten => _X.,1,NoOp(Inbound call from ${CALLERID(num)} to ${EXTEN}) same => n,Answer() same => n,Wait(1) same => n,Set(TIMEOUT(response)=10) same => n,Set(TIMEOUT(digit)=5) same => n(ivr),Background(custom/welcome) same => n,WaitExten(5) same => n,Goto(ivr-timeout,s,1) ; IVR option 1: Sales
exten => 1,1,NoOp(IVR: Sales selected) same => n,Playback(custom/transferring-sales) same => n,Goto(from-internal,2001,1) ; IVR option 2: Support
exten => 2,1,NoOp(IVR: Support selected) same => n,Playback(custom/transferring-support) same => n,Goto(from-internal,2002,1) ; IVR option 0: Operator
exten => 0,1,NoOp(IVR: Operator selected) same => n,Goto(from-internal,1001,1) ; IVR timeout or invalid
[ivr-timeout]
exten => s,1,NoOp(IVR timeout - routing to reception) same => n,Playback(custom/no-input) same => n,Goto(from-internal,1001,1) ; =============================================================================
; Default Context (catch-all, drop unknown traffic)
; =============================================================================
[default]
exten => _X.,1,NoOp(Dropping unrouted call to ${EXTEN} from ${CALLERID(num)}) same => n,Hangup(21) exten => _[a-z].,1,Hangup(21)
; =============================================================================
; Module Loading Configuration
; Load only what we need for a smaller memory footprint
; ============================================================================= [modules]
autoload = no ; ---- Core / Resource ----
load = res_pjproject.so
load = res_sorcery_astdb.so
load = res_sorcery_config.so
load = res_sorcery_memory.so
load = res_sorcery_memory_cache.so
load = res_sorcery_realtime.so
load = res_timing_timerfd.so
load = res_crypto.so
load = res_http_websocket.so
load = res_musiconhold.so
load = res_rtp_asterisk.so
load = res_rtp_multicast.so
load = res_speech.so
load = res_stasis.so
load = res_stasis_answer.so
load = res_stasis_playback.so
load = res_stasis_recording.so
load = res_stasis_snoop.so
load = res_stasis_device_state.so ; ---- PJSIP ----
load = res_pjsip.so
load = res_pjsip_authenticator_digest.so
load = res_pjsip_caller_id.so
load = res_pjsip_dialog_info_body_generator.so
load = res_pjsip_diversion.so
load = res_pjsip_dtmf_info.so
load = res_pjsip_endpoint_identifier_ip.so
load = res_pjsip_endpoint_identifier_user.so
load = res_pjsip_exten_state.so
load = res_pjsip_header_funcs.so
load = res_pjsip_logger.so
load = res_pjsip_messaging.so
load = res_pjsip_mwi.so
load = res_pjsip_mwi_body_generator.so
load = res_pjsip_nat.so
load = res_pjsip_notify.so
load = res_pjsip_outbound_authenticator_digest.so
load = res_pjsip_outbound_registration.so
load = res_pjsip_pidf_body_generator.so
load = res_pjsip_pidf_digium_body_supplement.so
load = res_pjsip_pidf_eyebeam_body_supplement.so
load = res_pjsip_publish_asterisk.so
load = res_pjsip_pubsub.so
load = res_pjsip_refer.so
load = res_pjsip_registrar.so
load = res_pjsip_rfc3326.so
load = res_pjsip_sdp_rtp.so
load = res_pjsip_send_to_voicemail.so
load = res_pjsip_session.so
load = res_pjsip_t38.so
load = res_pjsip_transport_websocket.so ; ---- ARI (Asterisk REST Interface) ----
load = res_ari.so
load = res_ari_applications.so
load = res_ari_asterisk.so
load = res_ari_bridges.so
load = res_ari_channels.so
load = res_ari_device_states.so
load = res_ari_endpoints.so
load = res_ari_events.so
load = res_ari_mailboxes.so
load = res_ari_model.so
load = res_ari_playbacks.so
load = res_ari_recordings.so
load = res_ari_sounds.so ; ---- Applications ----
load = app_bridgewait.so
load = app_confbridge.so
load = app_dial.so
load = app_directed_pickup.so
load = app_echo.so
load = app_exec.so
load = app_macro.so
load = app_mixmonitor.so
load = app_originate.so
load = app_playback.so
load = app_queue.so
load = app_read.so
load = app_record.so
load = app_sayunixtime.so
load = app_senddtmf.so
load = app_stack.so
load = app_stasis.so
load = app_transfer.so
load = app_verbose.so
load = app_voicemail.so
load = app_waituntil.so ; ---- Bridging ----
load = bridge_builtin_features.so
load = bridge_builtin_interval_features.so
load = bridge_holding.so
load = bridge_native_rtp.so
load = bridge_simple.so
load = bridge_softmix.so ; ---- CDR ----
load = cdr_adaptive_odbc.so
load = cdr_csv.so
load = cdr_custom.so
load = cdr_manager.so ; ---- Channel ----
load = chan_bridge_media.so
load = chan_pjsip.so
load = chan_rtp.so ; ---- Codecs ----
load = codec_a_mu.so
load = codec_adpcm.so
load = codec_alaw.so
load = codec_g722.so
load = codec_g726.so
load = codec_gsm.so
load = codec_opus.so
load = codec_resample.so
load = codec_speex.so
load = codec_ulaw.so ; ---- Formats ----
load = format_g722.so
load = format_g726.so
load = format_gsm.so
load = format_mp3.so
load = format_ogg_vorbis.so
load = format_pcm.so
load = format_sln.so
load = format_wav.so
load = format_wav_gsm.so ; ---- Functions ----
load = func_base64.so
load = func_callerid.so
load = func_cdr.so
load = func_channel.so
load = func_config.so
load = func_curl.so
load = func_cut.so
load = func_db.so
load = func_devstate.so
load = func_dialgroup.so
load = func_dialplan.so
load = func_env.so
load = func_global.so
load = func_groupcount.so
load = func_hangupcause.so
load = func_logic.so
load = func_math.so
load = func_md5.so
load = func_module.so
load = func_odbc.so
load = func_periodic_hook.so
load = func_pjsip_aor.so
load = func_pjsip_contact.so
load = func_pjsip_endpoint.so
load = func_realtime.so
load = func_shell.so
load = func_sprintf.so
load = func_strings.so
load = func_sysinfo.so
load = func_timeout.so
load = func_uri.so
load = func_version.so
load = func_volume.so ; ---- PBX ----
load = pbx_config.so
load = pbx_lounge.so
load = pbx_realtime.so ; ---- Database ----
load = res_config_odbc.so
load = res_odbc.so
load = res_odbc_transaction.so
load = func_realtime.so
; =============================================================================
; Module Loading Configuration
; Load only what we need for a smaller memory footprint
; ============================================================================= [modules]
autoload = no ; ---- Core / Resource ----
load = res_pjproject.so
load = res_sorcery_astdb.so
load = res_sorcery_config.so
load = res_sorcery_memory.so
load = res_sorcery_memory_cache.so
load = res_sorcery_realtime.so
load = res_timing_timerfd.so
load = res_crypto.so
load = res_http_websocket.so
load = res_musiconhold.so
load = res_rtp_asterisk.so
load = res_rtp_multicast.so
load = res_speech.so
load = res_stasis.so
load = res_stasis_answer.so
load = res_stasis_playback.so
load = res_stasis_recording.so
load = res_stasis_snoop.so
load = res_stasis_device_state.so ; ---- PJSIP ----
load = res_pjsip.so
load = res_pjsip_authenticator_digest.so
load = res_pjsip_caller_id.so
load = res_pjsip_dialog_info_body_generator.so
load = res_pjsip_diversion.so
load = res_pjsip_dtmf_info.so
load = res_pjsip_endpoint_identifier_ip.so
load = res_pjsip_endpoint_identifier_user.so
load = res_pjsip_exten_state.so
load = res_pjsip_header_funcs.so
load = res_pjsip_logger.so
load = res_pjsip_messaging.so
load = res_pjsip_mwi.so
load = res_pjsip_mwi_body_generator.so
load = res_pjsip_nat.so
load = res_pjsip_notify.so
load = res_pjsip_outbound_authenticator_digest.so
load = res_pjsip_outbound_registration.so
load = res_pjsip_pidf_body_generator.so
load = res_pjsip_pidf_digium_body_supplement.so
load = res_pjsip_pidf_eyebeam_body_supplement.so
load = res_pjsip_publish_asterisk.so
load = res_pjsip_pubsub.so
load = res_pjsip_refer.so
load = res_pjsip_registrar.so
load = res_pjsip_rfc3326.so
load = res_pjsip_sdp_rtp.so
load = res_pjsip_send_to_voicemail.so
load = res_pjsip_session.so
load = res_pjsip_t38.so
load = res_pjsip_transport_websocket.so ; ---- ARI (Asterisk REST Interface) ----
load = res_ari.so
load = res_ari_applications.so
load = res_ari_asterisk.so
load = res_ari_bridges.so
load = res_ari_channels.so
load = res_ari_device_states.so
load = res_ari_endpoints.so
load = res_ari_events.so
load = res_ari_mailboxes.so
load = res_ari_model.so
load = res_ari_playbacks.so
load = res_ari_recordings.so
load = res_ari_sounds.so ; ---- Applications ----
load = app_bridgewait.so
load = app_confbridge.so
load = app_dial.so
load = app_directed_pickup.so
load = app_echo.so
load = app_exec.so
load = app_macro.so
load = app_mixmonitor.so
load = app_originate.so
load = app_playback.so
load = app_queue.so
load = app_read.so
load = app_record.so
load = app_sayunixtime.so
load = app_senddtmf.so
load = app_stack.so
load = app_stasis.so
load = app_transfer.so
load = app_verbose.so
load = app_voicemail.so
load = app_waituntil.so ; ---- Bridging ----
load = bridge_builtin_features.so
load = bridge_builtin_interval_features.so
load = bridge_holding.so
load = bridge_native_rtp.so
load = bridge_simple.so
load = bridge_softmix.so ; ---- CDR ----
load = cdr_adaptive_odbc.so
load = cdr_csv.so
load = cdr_custom.so
load = cdr_manager.so ; ---- Channel ----
load = chan_bridge_media.so
load = chan_pjsip.so
load = chan_rtp.so ; ---- Codecs ----
load = codec_a_mu.so
load = codec_adpcm.so
load = codec_alaw.so
load = codec_g722.so
load = codec_g726.so
load = codec_gsm.so
load = codec_opus.so
load = codec_resample.so
load = codec_speex.so
load = codec_ulaw.so ; ---- Formats ----
load = format_g722.so
load = format_g726.so
load = format_gsm.so
load = format_mp3.so
load = format_ogg_vorbis.so
load = format_pcm.so
load = format_sln.so
load = format_wav.so
load = format_wav_gsm.so ; ---- Functions ----
load = func_base64.so
load = func_callerid.so
load = func_cdr.so
load = func_channel.so
load = func_config.so
load = func_curl.so
load = func_cut.so
load = func_db.so
load = func_devstate.so
load = func_dialgroup.so
load = func_dialplan.so
load = func_env.so
load = func_global.so
load = func_groupcount.so
load = func_hangupcause.so
load = func_logic.so
load = func_math.so
load = func_md5.so
load = func_module.so
load = func_odbc.so
load = func_periodic_hook.so
load = func_pjsip_aor.so
load = func_pjsip_contact.so
load = func_pjsip_endpoint.so
load = func_realtime.so
load = func_shell.so
load = func_sprintf.so
load = func_strings.so
load = func_sysinfo.so
load = func_timeout.so
load = func_uri.so
load = func_version.so
load = func_volume.so ; ---- PBX ----
load = pbx_config.so
load = pbx_lounge.so
load = pbx_realtime.so ; ---- Database ----
load = res_config_odbc.so
load = res_odbc.so
load = res_odbc_transaction.so
load = func_realtime.so
; =============================================================================
; Module Loading Configuration
; Load only what we need for a smaller memory footprint
; ============================================================================= [modules]
autoload = no ; ---- Core / Resource ----
load = res_pjproject.so
load = res_sorcery_astdb.so
load = res_sorcery_config.so
load = res_sorcery_memory.so
load = res_sorcery_memory_cache.so
load = res_sorcery_realtime.so
load = res_timing_timerfd.so
load = res_crypto.so
load = res_http_websocket.so
load = res_musiconhold.so
load = res_rtp_asterisk.so
load = res_rtp_multicast.so
load = res_speech.so
load = res_stasis.so
load = res_stasis_answer.so
load = res_stasis_playback.so
load = res_stasis_recording.so
load = res_stasis_snoop.so
load = res_stasis_device_state.so ; ---- PJSIP ----
load = res_pjsip.so
load = res_pjsip_authenticator_digest.so
load = res_pjsip_caller_id.so
load = res_pjsip_dialog_info_body_generator.so
load = res_pjsip_diversion.so
load = res_pjsip_dtmf_info.so
load = res_pjsip_endpoint_identifier_ip.so
load = res_pjsip_endpoint_identifier_user.so
load = res_pjsip_exten_state.so
load = res_pjsip_header_funcs.so
load = res_pjsip_logger.so
load = res_pjsip_messaging.so
load = res_pjsip_mwi.so
load = res_pjsip_mwi_body_generator.so
load = res_pjsip_nat.so
load = res_pjsip_notify.so
load = res_pjsip_outbound_authenticator_digest.so
load = res_pjsip_outbound_registration.so
load = res_pjsip_pidf_body_generator.so
load = res_pjsip_pidf_digium_body_supplement.so
load = res_pjsip_pidf_eyebeam_body_supplement.so
load = res_pjsip_publish_asterisk.so
load = res_pjsip_pubsub.so
load = res_pjsip_refer.so
load = res_pjsip_registrar.so
load = res_pjsip_rfc3326.so
load = res_pjsip_sdp_rtp.so
load = res_pjsip_send_to_voicemail.so
load = res_pjsip_session.so
load = res_pjsip_t38.so
load = res_pjsip_transport_websocket.so ; ---- ARI (Asterisk REST Interface) ----
load = res_ari.so
load = res_ari_applications.so
load = res_ari_asterisk.so
load = res_ari_bridges.so
load = res_ari_channels.so
load = res_ari_device_states.so
load = res_ari_endpoints.so
load = res_ari_events.so
load = res_ari_mailboxes.so
load = res_ari_model.so
load = res_ari_playbacks.so
load = res_ari_recordings.so
load = res_ari_sounds.so ; ---- Applications ----
load = app_bridgewait.so
load = app_confbridge.so
load = app_dial.so
load = app_directed_pickup.so
load = app_echo.so
load = app_exec.so
load = app_macro.so
load = app_mixmonitor.so
load = app_originate.so
load = app_playback.so
load = app_queue.so
load = app_read.so
load = app_record.so
load = app_sayunixtime.so
load = app_senddtmf.so
load = app_stack.so
load = app_stasis.so
load = app_transfer.so
load = app_verbose.so
load = app_voicemail.so
load = app_waituntil.so ; ---- Bridging ----
load = bridge_builtin_features.so
load = bridge_builtin_interval_features.so
load = bridge_holding.so
load = bridge_native_rtp.so
load = bridge_simple.so
load = bridge_softmix.so ; ---- CDR ----
load = cdr_adaptive_odbc.so
load = cdr_csv.so
load = cdr_custom.so
load = cdr_manager.so ; ---- Channel ----
load = chan_bridge_media.so
load = chan_pjsip.so
load = chan_rtp.so ; ---- Codecs ----
load = codec_a_mu.so
load = codec_adpcm.so
load = codec_alaw.so
load = codec_g722.so
load = codec_g726.so
load = codec_gsm.so
load = codec_opus.so
load = codec_resample.so
load = codec_speex.so
load = codec_ulaw.so ; ---- Formats ----
load = format_g722.so
load = format_g726.so
load = format_gsm.so
load = format_mp3.so
load = format_ogg_vorbis.so
load = format_pcm.so
load = format_sln.so
load = format_wav.so
load = format_wav_gsm.so ; ---- Functions ----
load = func_base64.so
load = func_callerid.so
load = func_cdr.so
load = func_channel.so
load = func_config.so
load = func_curl.so
load = func_cut.so
load = func_db.so
load = func_devstate.so
load = func_dialgroup.so
load = func_dialplan.so
load = func_env.so
load = func_global.so
load = func_groupcount.so
load = func_hangupcause.so
load = func_logic.so
load = func_math.so
load = func_md5.so
load = func_module.so
load = func_odbc.so
load = func_periodic_hook.so
load = func_pjsip_aor.so
load = func_pjsip_contact.so
load = func_pjsip_endpoint.so
load = func_realtime.so
load = func_shell.so
load = func_sprintf.so
load = func_strings.so
load = func_sysinfo.so
load = func_timeout.so
load = func_uri.so
load = func_version.so
load = func_volume.so ; ---- PBX ----
load = pbx_config.so
load = pbx_lounge.so
load = pbx_realtime.so ; ---- Database ----
load = res_config_odbc.so
load = res_odbc.so
load = res_odbc_transaction.so
load = func_realtime.so
; =============================================================================
; HTTP Server Configuration (for ARI and WebSocket)
; ============================================================================= [general]
enabled = yes
bindaddr = 0.0.0.0
bindport = 8088
; TLS is handled by Nginx reverse proxy
; For direct TLS access, uncomment below:
; tlsenable = yes
; tlsbindaddr = 0.0.0.0:8089
; tlscertfile = /etc/asterisk/certs/live/${DOMAIN}/fullchain.pem
; tlsprivatekey = /etc/asterisk/certs/live/${DOMAIN}/privkey.pem
prefix =
sessionlimit = 100
session_inactivity = 30000
session_keep_alive = 15000
; =============================================================================
; HTTP Server Configuration (for ARI and WebSocket)
; ============================================================================= [general]
enabled = yes
bindaddr = 0.0.0.0
bindport = 8088
; TLS is handled by Nginx reverse proxy
; For direct TLS access, uncomment below:
; tlsenable = yes
; tlsbindaddr = 0.0.0.0:8089
; tlscertfile = /etc/asterisk/certs/live/${DOMAIN}/fullchain.pem
; tlsprivatekey = /etc/asterisk/certs/live/${DOMAIN}/privkey.pem
prefix =
sessionlimit = 100
session_inactivity = 30000
session_keep_alive = 15000
; =============================================================================
; HTTP Server Configuration (for ARI and WebSocket)
; ============================================================================= [general]
enabled = yes
bindaddr = 0.0.0.0
bindport = 8088
; TLS is handled by Nginx reverse proxy
; For direct TLS access, uncomment below:
; tlsenable = yes
; tlsbindaddr = 0.0.0.0:8089
; tlscertfile = /etc/asterisk/certs/live/${DOMAIN}/fullchain.pem
; tlsprivatekey = /etc/asterisk/certs/live/${DOMAIN}/privkey.pem
prefix =
sessionlimit = 100
session_inactivity = 30000
session_keep_alive = 15000
; =============================================================================
; ARI (Asterisk REST Interface) Configuration
; ============================================================================= [general]
enabled = yes
pretty = yes
allowed_origins = https://${DOMAIN} [${ARI_USERNAME}]
type = user
read_only = no
password = ${ARI_PASSWORD}
; =============================================================================
; ARI (Asterisk REST Interface) Configuration
; ============================================================================= [general]
enabled = yes
pretty = yes
allowed_origins = https://${DOMAIN} [${ARI_USERNAME}]
type = user
read_only = no
password = ${ARI_PASSWORD}
; =============================================================================
; ARI (Asterisk REST Interface) Configuration
; ============================================================================= [general]
enabled = yes
pretty = yes
allowed_origins = https://${DOMAIN} [${ARI_USERNAME}]
type = user
read_only = no
password = ${ARI_PASSWORD}
; =============================================================================
; RTP Configuration
; Port range MUST match the Docker port mapping in docker-compose.yml
; ============================================================================= [general]
rtpstart = 10000
rtpend = 20000
; Strict RTP - helps prevent RTP injection attacks
strictrtp = yes
; Probation count before RTP source is accepted
probation = 4
; Enable symmetric RTP (important for NAT)
icesupport = yes
stunaddr = stun.l.google.com:19302
; RTP keepalives (prevent NAT timeout)
rtpkeepalive = 15
; DTMF timeout
dtmftimeout = 3000
; =============================================================================
; RTP Configuration
; Port range MUST match the Docker port mapping in docker-compose.yml
; ============================================================================= [general]
rtpstart = 10000
rtpend = 20000
; Strict RTP - helps prevent RTP injection attacks
strictrtp = yes
; Probation count before RTP source is accepted
probation = 4
; Enable symmetric RTP (important for NAT)
icesupport = yes
stunaddr = stun.l.google.com:19302
; RTP keepalives (prevent NAT timeout)
rtpkeepalive = 15
; DTMF timeout
dtmftimeout = 3000
; =============================================================================
; RTP Configuration
; Port range MUST match the Docker port mapping in docker-compose.yml
; ============================================================================= [general]
rtpstart = 10000
rtpend = 20000
; Strict RTP - helps prevent RTP injection attacks
strictrtp = yes
; Probation count before RTP source is accepted
probation = 4
; Enable symmetric RTP (important for NAT)
icesupport = yes
stunaddr = stun.l.google.com:19302
; RTP keepalives (prevent NAT timeout)
rtpkeepalive = 15
; DTMF timeout
dtmftimeout = 3000
; =============================================================================
; CDR (Call Detail Records) Configuration
; ============================================================================= [general]
enable = yes
unanswered = yes
congestion = yes
endbeforehexten = no
initiatedseconds = yes
batch = yes
size = 100
time = 300
; =============================================================================
; CDR (Call Detail Records) Configuration
; ============================================================================= [general]
enable = yes
unanswered = yes
congestion = yes
endbeforehexten = no
initiatedseconds = yes
batch = yes
size = 100
time = 300
; =============================================================================
; CDR (Call Detail Records) Configuration
; ============================================================================= [general]
enable = yes
unanswered = yes
congestion = yes
endbeforehexten = no
initiatedseconds = yes
batch = yes
size = 100
time = 300
; =============================================================================
; CDR Adaptive ODBC - Write CDR to MariaDB
; ============================================================================= [asterisk-cdr]
connection = asterisk-connector
table = cdr
alias start => calldate
alias clid => clid
alias src => src
alias dst => dst
alias dcontext => dcontext
alias channel => channel
alias dstchannel => dstchannel
alias lastapp => lastapp
alias lastdata => lastdata
alias duration => duration
alias billsec => billsec
alias disposition => disposition
alias amaflags => amaflags
alias accountcode => accountcode
alias uniqueid => uniqueid
alias userfield => userfield
alias peeraccount => peeraccount
alias linkedid => linkedid
alias sequence => sequence
; =============================================================================
; CDR Adaptive ODBC - Write CDR to MariaDB
; ============================================================================= [asterisk-cdr]
connection = asterisk-connector
table = cdr
alias start => calldate
alias clid => clid
alias src => src
alias dst => dst
alias dcontext => dcontext
alias channel => channel
alias dstchannel => dstchannel
alias lastapp => lastapp
alias lastdata => lastdata
alias duration => duration
alias billsec => billsec
alias disposition => disposition
alias amaflags => amaflags
alias accountcode => accountcode
alias uniqueid => uniqueid
alias userfield => userfield
alias peeraccount => peeraccount
alias linkedid => linkedid
alias sequence => sequence
; =============================================================================
; CDR Adaptive ODBC - Write CDR to MariaDB
; ============================================================================= [asterisk-cdr]
connection = asterisk-connector
table = cdr
alias start => calldate
alias clid => clid
alias src => src
alias dst => dst
alias dcontext => dcontext
alias channel => channel
alias dstchannel => dstchannel
alias lastapp => lastapp
alias lastdata => lastdata
alias duration => duration
alias billsec => billsec
alias disposition => disposition
alias amaflags => amaflags
alias accountcode => accountcode
alias uniqueid => uniqueid
alias userfield => userfield
alias peeraccount => peeraccount
alias linkedid => linkedid
alias sequence => sequence
; =============================================================================
; ODBC Connection Configuration
; ============================================================================= [asterisk-connector]
enabled = yes
dsn = asterisk-connector
username = ${DB_USER}
password = ${DB_PASSWORD}
pre-connect = yes
sanitysql = SELECT 1
max_connections = 20
connect_timeout = 5
negative_connection_cache = 600
; =============================================================================
; ODBC Connection Configuration
; ============================================================================= [asterisk-connector]
enabled = yes
dsn = asterisk-connector
username = ${DB_USER}
password = ${DB_PASSWORD}
pre-connect = yes
sanitysql = SELECT 1
max_connections = 20
connect_timeout = 5
negative_connection_cache = 600
; =============================================================================
; ODBC Connection Configuration
; ============================================================================= [asterisk-connector]
enabled = yes
dsn = asterisk-connector
username = ${DB_USER}
password = ${DB_PASSWORD}
pre-connect = yes
sanitysql = SELECT 1
max_connections = 20
connect_timeout = 5
negative_connection_cache = 600
[asterisk-connector]
Description = MariaDB Asterisk Connection
Driver = /usr/lib/x86_64-linux-gnu/odbc/libmaodbc.so
Server = ${DB_HOST}
Port = ${DB_PORT}
Database = ${DB_NAME}
Option = 3
Socket =
[asterisk-connector]
Description = MariaDB Asterisk Connection
Driver = /usr/lib/x86_64-linux-gnu/odbc/libmaodbc.so
Server = ${DB_HOST}
Port = ${DB_PORT}
Database = ${DB_NAME}
Option = 3
Socket =
[asterisk-connector]
Description = MariaDB Asterisk Connection
Driver = /usr/lib/x86_64-linux-gnu/odbc/libmaodbc.so
Server = ${DB_HOST}
Port = ${DB_PORT}
Database = ${DB_NAME}
Option = 3
Socket =
; =============================================================================
; Voicemail Configuration
; ============================================================================= [general]
format = wav49|gsm|wav
serveremail = voicemail@${DOMAIN}
attach = yes
skipms = 3000
maxsilence = 10
silencethreshold = 128
maxlogins = 3
minsecs = 3
maxsecs = 300
maxmsg = 100
moveheard = yes
forward_urgent_auto = yes
; Email notification (requires sendmail/msmtp in container)
; emailsubject = New voicemail ${VM_MSGNUM} in mailbox ${VM_MAILBOX}
; emailbody = Dear ${VM_NAME},\n\nYou have a new voicemail from ${VM_CALLERID}\nDuration: ${VM_DUR}\nDate: ${VM_DATE}\n
emaildateformat = %A, %B %d, %Y at %r [default]
1001 => 1234,Reception,reception@${DOMAIN},,
1002 => 1234,Sales,sales@${DOMAIN},,
1003 => 1234,Sales 2,,,
1004 => 1234,Support,support@${DOMAIN},,
1005 => 1234,Support 2,,,
1006 => 1234,Support 3,,,
1010 => 1234,Web Phone,webphone@${DOMAIN},,
2001 => 0000,Sales Group,sales@${DOMAIN},,
2002 => 0000,Support Group,support@${DOMAIN},,
; =============================================================================
; Voicemail Configuration
; ============================================================================= [general]
format = wav49|gsm|wav
serveremail = voicemail@${DOMAIN}
attach = yes
skipms = 3000
maxsilence = 10
silencethreshold = 128
maxlogins = 3
minsecs = 3
maxsecs = 300
maxmsg = 100
moveheard = yes
forward_urgent_auto = yes
; Email notification (requires sendmail/msmtp in container)
; emailsubject = New voicemail ${VM_MSGNUM} in mailbox ${VM_MAILBOX}
; emailbody = Dear ${VM_NAME},\n\nYou have a new voicemail from ${VM_CALLERID}\nDuration: ${VM_DUR}\nDate: ${VM_DATE}\n
emaildateformat = %A, %B %d, %Y at %r [default]
1001 => 1234,Reception,reception@${DOMAIN},,
1002 => 1234,Sales,sales@${DOMAIN},,
1003 => 1234,Sales 2,,,
1004 => 1234,Support,support@${DOMAIN},,
1005 => 1234,Support 2,,,
1006 => 1234,Support 3,,,
1010 => 1234,Web Phone,webphone@${DOMAIN},,
2001 => 0000,Sales Group,sales@${DOMAIN},,
2002 => 0000,Support Group,support@${DOMAIN},,
; =============================================================================
; Voicemail Configuration
; ============================================================================= [general]
format = wav49|gsm|wav
serveremail = voicemail@${DOMAIN}
attach = yes
skipms = 3000
maxsilence = 10
silencethreshold = 128
maxlogins = 3
minsecs = 3
maxsecs = 300
maxmsg = 100
moveheard = yes
forward_urgent_auto = yes
; Email notification (requires sendmail/msmtp in container)
; emailsubject = New voicemail ${VM_MSGNUM} in mailbox ${VM_MAILBOX}
; emailbody = Dear ${VM_NAME},\n\nYou have a new voicemail from ${VM_CALLERID}\nDuration: ${VM_DUR}\nDate: ${VM_DATE}\n
emaildateformat = %A, %B %d, %Y at %r [default]
1001 => 1234,Reception,reception@${DOMAIN},,
1002 => 1234,Sales,sales@${DOMAIN},,
1003 => 1234,Sales 2,,,
1004 => 1234,Support,support@${DOMAIN},,
1005 => 1234,Support 2,,,
1006 => 1234,Support 3,,,
1010 => 1234,Web Phone,webphone@${DOMAIN},,
2001 => 0000,Sales Group,sales@${DOMAIN},,
2002 => 0000,Support Group,support@${DOMAIN},,
; =============================================================================
; ConfBridge Configuration (Conference Rooms)
; Uses res_timing_timerfd - no DAHDI needed
; ============================================================================= [general] [default_bridge]
type = bridge
max_members = 50
record_conference = no
internal_sample_rate = auto
mixing_interval = 20
video_mode = follow_talker
sound_join = confbridge-join
sound_leave = confbridge-leave
sound_has_joined = confbridge-has-joined
sound_has_left = confbridge-has-left [default_user]
type = user
announce_user_count = yes
announce_user_count_all = yes
announce_join_leave = yes
music_on_hold_when_empty = yes
quiet = no
startmuted = no
wait_marked = no
end_marked = no
dsp_drop_silence = yes
denoise = yes
talk_detection_events = yes
dtmf_passthrough = no [admin_user]
type = user
announce_user_count = yes
announce_join_leave = yes
music_on_hold_when_empty = yes
admin = yes
marked = yes
startmuted = no
wait_marked = no
end_marked = no
dsp_drop_silence = yes
denoise = yes
; =============================================================================
; ConfBridge Configuration (Conference Rooms)
; Uses res_timing_timerfd - no DAHDI needed
; ============================================================================= [general] [default_bridge]
type = bridge
max_members = 50
record_conference = no
internal_sample_rate = auto
mixing_interval = 20
video_mode = follow_talker
sound_join = confbridge-join
sound_leave = confbridge-leave
sound_has_joined = confbridge-has-joined
sound_has_left = confbridge-has-left [default_user]
type = user
announce_user_count = yes
announce_user_count_all = yes
announce_join_leave = yes
music_on_hold_when_empty = yes
quiet = no
startmuted = no
wait_marked = no
end_marked = no
dsp_drop_silence = yes
denoise = yes
talk_detection_events = yes
dtmf_passthrough = no [admin_user]
type = user
announce_user_count = yes
announce_join_leave = yes
music_on_hold_when_empty = yes
admin = yes
marked = yes
startmuted = no
wait_marked = no
end_marked = no
dsp_drop_silence = yes
denoise = yes
; =============================================================================
; ConfBridge Configuration (Conference Rooms)
; Uses res_timing_timerfd - no DAHDI needed
; ============================================================================= [general] [default_bridge]
type = bridge
max_members = 50
record_conference = no
internal_sample_rate = auto
mixing_interval = 20
video_mode = follow_talker
sound_join = confbridge-join
sound_leave = confbridge-leave
sound_has_joined = confbridge-has-joined
sound_has_left = confbridge-has-left [default_user]
type = user
announce_user_count = yes
announce_user_count_all = yes
announce_join_leave = yes
music_on_hold_when_empty = yes
quiet = no
startmuted = no
wait_marked = no
end_marked = no
dsp_drop_silence = yes
denoise = yes
talk_detection_events = yes
dtmf_passthrough = no [admin_user]
type = user
announce_user_count = yes
announce_join_leave = yes
music_on_hold_when_empty = yes
admin = yes
marked = yes
startmuted = no
wait_marked = no
end_marked = no
dsp_drop_silence = yes
denoise = yes
SIP Phone (Public) Docker Host Asterisk Container
203.0.113.50 198.51.100.10 172.25.0.2 │ │ │ │◄── SIP INVITE ──────────────►│◄── port forward ────────►│ │ (signaling works fine) │ (5060 mapped) │ │ │ │ │ RTP: "Send audio to │ │ │ 172.25.0.2:15000" │ ← PROBLEM! │ │ Phone can't reach that │ │ │ │ │
SIP Phone (Public) Docker Host Asterisk Container
203.0.113.50 198.51.100.10 172.25.0.2 │ │ │ │◄── SIP INVITE ──────────────►│◄── port forward ────────►│ │ (signaling works fine) │ (5060 mapped) │ │ │ │ │ RTP: "Send audio to │ │ │ 172.25.0.2:15000" │ ← PROBLEM! │ │ Phone can't reach that │ │ │ │ │
SIP Phone (Public) Docker Host Asterisk Container
203.0.113.50 198.51.100.10 172.25.0.2 │ │ │ │◄── SIP INVITE ──────────────►│◄── port forward ────────►│ │ (signaling works fine) │ (5060 mapped) │ │ │ │ │ RTP: "Send audio to │ │ │ 172.25.0.2:15000" │ ← PROBLEM! │ │ Phone can't reach that │ │ │ │ │
; On each transport:
external_media_address = ${PJSIP_EXTERNAL_IP} ; Tell peers to send RTP here
external_signaling_address = ${PJSIP_EXTERNAL_IP} ; Tell peers to send SIP here
local_net = 172.25.0.0/24 ; Docker bridge subnet
local_net = 10.0.0.0/8 ; Other private ranges
local_net = 172.16.0.0/12
local_net = 192.168.0.0/16 ; On each endpoint:
direct_media = no ; Force media through Asterisk (don't try peer-to-peer)
rtp_symmetric = yes ; Send RTP back to the source address we received from
force_rport = yes ; Use the source port from the Contact header
rewrite_contact = yes ; Rewrite Contact to the address we see the peer at
; On each transport:
external_media_address = ${PJSIP_EXTERNAL_IP} ; Tell peers to send RTP here
external_signaling_address = ${PJSIP_EXTERNAL_IP} ; Tell peers to send SIP here
local_net = 172.25.0.0/24 ; Docker bridge subnet
local_net = 10.0.0.0/8 ; Other private ranges
local_net = 172.16.0.0/12
local_net = 192.168.0.0/16 ; On each endpoint:
direct_media = no ; Force media through Asterisk (don't try peer-to-peer)
rtp_symmetric = yes ; Send RTP back to the source address we received from
force_rport = yes ; Use the source port from the Contact header
rewrite_contact = yes ; Rewrite Contact to the address we see the peer at
; On each transport:
external_media_address = ${PJSIP_EXTERNAL_IP} ; Tell peers to send RTP here
external_signaling_address = ${PJSIP_EXTERNAL_IP} ; Tell peers to send SIP here
local_net = 172.25.0.0/24 ; Docker bridge subnet
local_net = 10.0.0.0/8 ; Other private ranges
local_net = 172.16.0.0/12
local_net = 192.168.0.0/16 ; On each endpoint:
direct_media = no ; Force media through Asterisk (don't try peer-to-peer)
rtp_symmetric = yes ; Send RTP back to the source address we received from
force_rport = yes ; Use the source port from the Contact header
rewrite_contact = yes ; Rewrite Contact to the address we see the peer at
# In docker-compose.yml, for the asterisk service:
asterisk: # Remove: networks, ports network_mode: host environment: # PJSIP_EXTERNAL_IP is still needed if behind a cloud NAT/firewall - PJSIP_EXTERNAL_IP=${PJSIP_EXTERNAL_IP}
# In docker-compose.yml, for the asterisk service:
asterisk: # Remove: networks, ports network_mode: host environment: # PJSIP_EXTERNAL_IP is still needed if behind a cloud NAT/firewall - PJSIP_EXTERNAL_IP=${PJSIP_EXTERNAL_IP}
# In docker-compose.yml, for the asterisk service:
asterisk: # Remove: networks, ports network_mode: host environment: # PJSIP_EXTERNAL_IP is still needed if behind a cloud NAT/firewall - PJSIP_EXTERNAL_IP=${PJSIP_EXTERNAL_IP}
networks: asterisk-macvlan: driver: macvlan driver_opts: parent: eth0 # Your host's network interface ipam: config: - subnet: 198.51.100.0/24 gateway: 198.51.100.1 services: asterisk: networks: asterisk-macvlan: ipv4_address: 198.51.100.20 # Unique IP for the container
networks: asterisk-macvlan: driver: macvlan driver_opts: parent: eth0 # Your host's network interface ipam: config: - subnet: 198.51.100.0/24 gateway: 198.51.100.1 services: asterisk: networks: asterisk-macvlan: ipv4_address: 198.51.100.20 # Unique IP for the container
networks: asterisk-macvlan: driver: macvlan driver_opts: parent: eth0 # Your host's network interface ipam: config: - subnet: 198.51.100.0/24 gateway: 198.51.100.1 services: asterisk: networks: asterisk-macvlan: ipv4_address: 198.51.100.20 # Unique IP for the container
; rtp.conf - Smaller port range for fewer calls
[general]
rtpstart = 10000
rtpend = 10200 ; ~100 simultaneous calls
; rtp.conf - Smaller port range for fewer calls
[general]
rtpstart = 10000
rtpend = 10200 ; ~100 simultaneous calls
; rtp.conf - Smaller port range for fewer calls
[general]
rtpstart = 10000
rtpend = 10200 ; ~100 simultaneous calls
ports: - "10000-10200:10000-10200/udp"
ports: - "10000-10200:10000-10200/udp"
ports: - "10000-10200:10000-10200/udp"
Browser (WebRTC) TURN Server (Coturn) Asterisk Container
10.0.0.50 (private) 198.51.100.10:3478 172.25.0.2 │ │ │ │── STUN Binding Req ──►│ │ │◄── Your public IP is │ │ │ 203.0.113.50:45000 │ │ │ │ │ │── TURN Allocate Req ─►│ │ │◄── Relay address: │ │ │ 198.51.100.10:49200│ │ │ │ │ │ ICE Negotiation │ SIP/WebSocket │ │◄──────────────────────►◄────────────────────────►│ │ │ │ │◄── RTP via TURN ──────►◄── RTP ────────────────►│
Browser (WebRTC) TURN Server (Coturn) Asterisk Container
10.0.0.50 (private) 198.51.100.10:3478 172.25.0.2 │ │ │ │── STUN Binding Req ──►│ │ │◄── Your public IP is │ │ │ 203.0.113.50:45000 │ │ │ │ │ │── TURN Allocate Req ─►│ │ │◄── Relay address: │ │ │ 198.51.100.10:49200│ │ │ │ │ │ ICE Negotiation │ SIP/WebSocket │ │◄──────────────────────►◄────────────────────────►│ │ │ │ │◄── RTP via TURN ──────►◄── RTP ────────────────►│
Browser (WebRTC) TURN Server (Coturn) Asterisk Container
10.0.0.50 (private) 198.51.100.10:3478 172.25.0.2 │ │ │ │── STUN Binding Req ──►│ │ │◄── Your public IP is │ │ │ 203.0.113.50:45000 │ │ │ │ │ │── TURN Allocate Req ─►│ │ │◄── Relay address: │ │ │ 198.51.100.10:49200│ │ │ │ │ │ ICE Negotiation │ SIP/WebSocket │ │◄──────────────────────►◄────────────────────────►│ │ │ │ │◄── RTP via TURN ──────►◄── RTP ────────────────►│
stunaddr = stun.l.google.com:19302
icesupport = yes
stunaddr = stun.l.google.com:19302
icesupport = yes
stunaddr = stun.l.google.com:19302
icesupport = yes
# 1. Check RTP ports are actually mapped
docker compose exec asterisk ss -ulnp | grep -E '1[0-9]{4}' # 2. Check what SDP Asterisk is sending (look for the c= line)
docker compose exec asterisk asterisk -rx "pjsip set logger on"
# Make a test call and look for:
# c=IN IP4 198.51.100.10 (should be your public IP)
# NOT: c=IN IP4 172.25.0.2 (container IP = broken) # 3. Capture RTP traffic on the host
tcpdump -i any -n udp portrange 10000-20000 -c 50 # 4. Check if RTP is flowing in both directions
docker compose exec asterisk asterisk -rx "rtp set debug on"
# You should see "Got RTP packet from..." AND "Sent RTP packet to..."
# If you only see one direction, that's your one-way audio # 5. Check NAT settings are applied
docker compose exec asterisk asterisk -rx "pjsip show transport transport-udp"
# Verify external_media_address and external_signaling_address # 6. Use sngrep to see the full SIP exchange
docker compose exec asterisk sngrep
# 1. Check RTP ports are actually mapped
docker compose exec asterisk ss -ulnp | grep -E '1[0-9]{4}' # 2. Check what SDP Asterisk is sending (look for the c= line)
docker compose exec asterisk asterisk -rx "pjsip set logger on"
# Make a test call and look for:
# c=IN IP4 198.51.100.10 (should be your public IP)
# NOT: c=IN IP4 172.25.0.2 (container IP = broken) # 3. Capture RTP traffic on the host
tcpdump -i any -n udp portrange 10000-20000 -c 50 # 4. Check if RTP is flowing in both directions
docker compose exec asterisk asterisk -rx "rtp set debug on"
# You should see "Got RTP packet from..." AND "Sent RTP packet to..."
# If you only see one direction, that's your one-way audio # 5. Check NAT settings are applied
docker compose exec asterisk asterisk -rx "pjsip show transport transport-udp"
# Verify external_media_address and external_signaling_address # 6. Use sngrep to see the full SIP exchange
docker compose exec asterisk sngrep
# 1. Check RTP ports are actually mapped
docker compose exec asterisk ss -ulnp | grep -E '1[0-9]{4}' # 2. Check what SDP Asterisk is sending (look for the c= line)
docker compose exec asterisk asterisk -rx "pjsip set logger on"
# Make a test call and look for:
# c=IN IP4 198.51.100.10 (should be your public IP)
# NOT: c=IN IP4 172.25.0.2 (container IP = broken) # 3. Capture RTP traffic on the host
tcpdump -i any -n udp portrange 10000-20000 -c 50 # 4. Check if RTP is flowing in both directions
docker compose exec asterisk asterisk -rx "rtp set debug on"
# You should see "Got RTP packet from..." AND "Sent RTP packet to..."
# If you only see one direction, that's your one-way audio # 5. Check NAT settings are applied
docker compose exec asterisk asterisk -rx "pjsip show transport transport-udp"
# Verify external_media_address and external_signaling_address # 6. Use sngrep to see the full SIP exchange
docker compose exec asterisk sngrep
Need multiple Asterisk containers on one host? └── YES → Use bridge networking with careful NAT config └── NO ─┐ ├── Have spare public IPs? │ └── YES → Macvlan (cleanest isolation) │ └── NO ──┐ │ ├── WebRTC only (no SIP trunks)? │ │ └── Bridge + TURN server │ └── SIP trunks with real carriers? │ └── Host networking (simplest, proven) └──────────────────────────────────────────────
Need multiple Asterisk containers on one host? └── YES → Use bridge networking with careful NAT config └── NO ─┐ ├── Have spare public IPs? │ └── YES → Macvlan (cleanest isolation) │ └── NO ──┐ │ ├── WebRTC only (no SIP trunks)? │ │ └── Bridge + TURN server │ └── SIP trunks with real carriers? │ └── Host networking (simplest, proven) └──────────────────────────────────────────────
Need multiple Asterisk containers on one host? └── YES → Use bridge networking with careful NAT config └── NO ─┐ ├── Have spare public IPs? │ └── YES → Macvlan (cleanest isolation) │ └── NO ──┐ │ ├── WebRTC only (no SIP trunks)? │ │ └── Bridge + TURN server │ └── SIP trunks with real carriers? │ └── Host networking (simplest, proven) └──────────────────────────────────────────────
# Bind mount - you control the exact path on the host
volumes: recordings: driver: local driver_opts: type: none device: /opt/asterisk-docker/data/recordings o: bind # Named volume - Docker manages the path
volumes: mariadb-data: driver: local
# Bind mount - you control the exact path on the host
volumes: recordings: driver: local driver_opts: type: none device: /opt/asterisk-docker/data/recordings o: bind # Named volume - Docker manages the path
volumes: mariadb-data: driver: local
# Bind mount - you control the exact path on the host
volumes: recordings: driver: local driver_opts: type: none device: /opt/asterisk-docker/data/recordings o: bind # Named volume - Docker manages the path
volumes: mariadb-data: driver: local
# Create recording directory structure
mkdir -p /opt/asterisk-docker/data/recordings # Set up a separate mount point for recordings if you have a dedicated disk
# (recommended for production)
# Example: mount a dedicated partition
# mkfs.ext4 /dev/sdb1
# echo '/dev/sdb1 /opt/asterisk-docker/data/recordings ext4 defaults,noatime 0 2' >> /etc/fstab
# mount /opt/asterisk-docker/data/recordings
# Create recording directory structure
mkdir -p /opt/asterisk-docker/data/recordings # Set up a separate mount point for recordings if you have a dedicated disk
# (recommended for production)
# Example: mount a dedicated partition
# mkfs.ext4 /dev/sdb1
# echo '/dev/sdb1 /opt/asterisk-docker/data/recordings ext4 defaults,noatime 0 2' >> /etc/fstab
# mount /opt/asterisk-docker/data/recordings
# Create recording directory structure
mkdir -p /opt/asterisk-docker/data/recordings # Set up a separate mount point for recordings if you have a dedicated disk
# (recommended for production)
# Example: mount a dedicated partition
# mkfs.ext4 /dev/sdb1
# echo '/dev/sdb1 /opt/asterisk-docker/data/recordings ext4 defaults,noatime 0 2' >> /etc/fstab
# mount /opt/asterisk-docker/data/recordings
#!/bin/bash
# =============================================================================
# Recording Cleanup Script
# Removes recordings older than RETENTION_DAYS
# Run via cron on the Docker host (not inside the container)
# ============================================================================= RECORDING_DIR="/opt/asterisk-docker/data/recordings"
RETENTION_DAYS="${1:-90}" # Default 90 days, override with first argument
LOG_FILE="/var/log/asterisk-recording-cleanup.log" echo "$(date '+%Y-%m-%d %H:%M:%S') Starting recording cleanup (retention: ${RETENTION_DAYS} days)" >> "$LOG_FILE" # Count files before cleanup
BEFORE=$(find "$RECORDING_DIR" -type f -name "*.wav" -o -name "*.WAV" -o -name "*.gsm" -o -name "*.mp3" | wc -l)
BEFORE_SIZE=$(du -sh "$RECORDING_DIR" 2>/dev/null | cut -f1) # Delete old recordings
find "$RECORDING_DIR" -type f \( -name "*.wav" -o -name "*.WAV" -o -name "*.gsm" -o -name "*.mp3" \) \ -mtime +${RETENTION_DAYS} -delete # Delete empty directories left behind
find "$RECORDING_DIR" -type d -empty -delete 2>/dev/null # Count files after cleanup
AFTER=$(find "$RECORDING_DIR" -type f -name "*.wav" -o -name "*.WAV" -o -name "*.gsm" -o -name "*.mp3" | wc -l)
AFTER_SIZE=$(du -sh "$RECORDING_DIR" 2>/dev/null | cut -f1) DELETED=$((BEFORE - AFTER))
echo "$(date '+%Y-%m-%d %H:%M:%S') Cleanup complete: deleted $DELETED files. Before: $BEFORE ($BEFORE_SIZE) After: $AFTER ($AFTER_SIZE)" >> "$LOG_FILE"
#!/bin/bash
# =============================================================================
# Recording Cleanup Script
# Removes recordings older than RETENTION_DAYS
# Run via cron on the Docker host (not inside the container)
# ============================================================================= RECORDING_DIR="/opt/asterisk-docker/data/recordings"
RETENTION_DAYS="${1:-90}" # Default 90 days, override with first argument
LOG_FILE="/var/log/asterisk-recording-cleanup.log" echo "$(date '+%Y-%m-%d %H:%M:%S') Starting recording cleanup (retention: ${RETENTION_DAYS} days)" >> "$LOG_FILE" # Count files before cleanup
BEFORE=$(find "$RECORDING_DIR" -type f -name "*.wav" -o -name "*.WAV" -o -name "*.gsm" -o -name "*.mp3" | wc -l)
BEFORE_SIZE=$(du -sh "$RECORDING_DIR" 2>/dev/null | cut -f1) # Delete old recordings
find "$RECORDING_DIR" -type f \( -name "*.wav" -o -name "*.WAV" -o -name "*.gsm" -o -name "*.mp3" \) \ -mtime +${RETENTION_DAYS} -delete # Delete empty directories left behind
find "$RECORDING_DIR" -type d -empty -delete 2>/dev/null # Count files after cleanup
AFTER=$(find "$RECORDING_DIR" -type f -name "*.wav" -o -name "*.WAV" -o -name "*.gsm" -o -name "*.mp3" | wc -l)
AFTER_SIZE=$(du -sh "$RECORDING_DIR" 2>/dev/null | cut -f1) DELETED=$((BEFORE - AFTER))
echo "$(date '+%Y-%m-%d %H:%M:%S') Cleanup complete: deleted $DELETED files. Before: $BEFORE ($BEFORE_SIZE) After: $AFTER ($AFTER_SIZE)" >> "$LOG_FILE"
#!/bin/bash
# =============================================================================
# Recording Cleanup Script
# Removes recordings older than RETENTION_DAYS
# Run via cron on the Docker host (not inside the container)
# ============================================================================= RECORDING_DIR="/opt/asterisk-docker/data/recordings"
RETENTION_DAYS="${1:-90}" # Default 90 days, override with first argument
LOG_FILE="/var/log/asterisk-recording-cleanup.log" echo "$(date '+%Y-%m-%d %H:%M:%S') Starting recording cleanup (retention: ${RETENTION_DAYS} days)" >> "$LOG_FILE" # Count files before cleanup
BEFORE=$(find "$RECORDING_DIR" -type f -name "*.wav" -o -name "*.WAV" -o -name "*.gsm" -o -name "*.mp3" | wc -l)
BEFORE_SIZE=$(du -sh "$RECORDING_DIR" 2>/dev/null | cut -f1) # Delete old recordings
find "$RECORDING_DIR" -type f \( -name "*.wav" -o -name "*.WAV" -o -name "*.gsm" -o -name "*.mp3" \) \ -mtime +${RETENTION_DAYS} -delete # Delete empty directories left behind
find "$RECORDING_DIR" -type d -empty -delete 2>/dev/null # Count files after cleanup
AFTER=$(find "$RECORDING_DIR" -type f -name "*.wav" -o -name "*.WAV" -o -name "*.gsm" -o -name "*.mp3" | wc -l)
AFTER_SIZE=$(du -sh "$RECORDING_DIR" 2>/dev/null | cut -f1) DELETED=$((BEFORE - AFTER))
echo "$(date '+%Y-%m-%d %H:%M:%S') Cleanup complete: deleted $DELETED files. Before: $BEFORE ($BEFORE_SIZE) After: $AFTER ($AFTER_SIZE)" >> "$LOG_FILE"
# Make executable and add to cron
chmod +x /opt/asterisk-docker/scripts/cleanup-recordings.sh # Run daily at 3 AM, keep 90 days
(crontab -l 2>/dev/null; echo "0 3 * * * /opt/asterisk-docker/scripts/cleanup-recordings.sh 90") | crontab -
# Make executable and add to cron
chmod +x /opt/asterisk-docker/scripts/cleanup-recordings.sh # Run daily at 3 AM, keep 90 days
(crontab -l 2>/dev/null; echo "0 3 * * * /opt/asterisk-docker/scripts/cleanup-recordings.sh 90") | crontab -
# Make executable and add to cron
chmod +x /opt/asterisk-docker/scripts/cleanup-recordings.sh # Run daily at 3 AM, keep 90 days
(crontab -l 2>/dev/null; echo "0 3 * * * /opt/asterisk-docker/scripts/cleanup-recordings.sh 90") | crontab -
/opt/asterisk-docker/data/logs/messages
/opt/asterisk-docker/data/logs/full
/opt/asterisk-docker/data/logs/error
/opt/asterisk-docker/data/logs/verbose
{ daily rotate 7 compress delaycompress missingok notifempty create 0640 1000 1000 postrotate docker exec asterisk asterisk -rx "logger reload" 2>/dev/null || true endscript
} /opt/asterisk-docker/data/logs/cdr-csv/*.csv
{ monthly rotate 12 compress delaycompress missingok notifempty create 0640 1000 1000
}
/opt/asterisk-docker/data/logs/messages
/opt/asterisk-docker/data/logs/full
/opt/asterisk-docker/data/logs/error
/opt/asterisk-docker/data/logs/verbose
{ daily rotate 7 compress delaycompress missingok notifempty create 0640 1000 1000 postrotate docker exec asterisk asterisk -rx "logger reload" 2>/dev/null || true endscript
} /opt/asterisk-docker/data/logs/cdr-csv/*.csv
{ monthly rotate 12 compress delaycompress missingok notifempty create 0640 1000 1000
}
/opt/asterisk-docker/data/logs/messages
/opt/asterisk-docker/data/logs/full
/opt/asterisk-docker/data/logs/error
/opt/asterisk-docker/data/logs/verbose
{ daily rotate 7 compress delaycompress missingok notifempty create 0640 1000 1000 postrotate docker exec asterisk asterisk -rx "logger reload" 2>/dev/null || true endscript
} /opt/asterisk-docker/data/logs/cdr-csv/*.csv
{ monthly rotate 12 compress delaycompress missingok notifempty create 0640 1000 1000
}
# Install logrotate config
cp /opt/asterisk-docker/scripts/logrotate-asterisk.conf /etc/logrotate.d/asterisk-docker
# Install logrotate config
cp /opt/asterisk-docker/scripts/logrotate-asterisk.conf /etc/logrotate.d/asterisk-docker
# Install logrotate config
cp /opt/asterisk-docker/scripts/logrotate-asterisk.conf /etc/logrotate.d/asterisk-docker
#!/bin/bash
# =============================================================================
# Asterisk Docker Stack - Full Backup
# Backs up: MariaDB dump, recordings, voicemail, configs, certificates
# ============================================================================= set -e BACKUP_DIR="/opt/asterisk-docker/backups"
DATE=$(date '+%Y%m%d_%H%M%S')
BACKUP_PATH="$BACKUP_DIR/$DATE"
RETENTION_DAYS=30 # Source environment
set -a
source /opt/asterisk-docker/.env
set +a echo "=== Asterisk Backup Starting: $DATE ===" mkdir -p "$BACKUP_PATH" # ---- 1. Database dump ----
echo "Backing up MariaDB..."
docker exec asterisk-mariadb mysqldump \ -u root -p"${DB_ROOT_PASSWORD}" \ --all-databases \ --single-transaction \ --routines \ --triggers \ --events \ > "$BACKUP_PATH/mariadb-all-databases.sql"
echo " Database dump: $(du -sh "$BACKUP_PATH/mariadb-all-databases.sql" | cut -f1)" # ---- 2. Configuration files ----
echo "Backing up configuration..."
tar czf "$BACKUP_PATH/configs.tar.gz" \ -C /opt/asterisk-docker \ asterisk/configs/ \ nginx/ \ coturn/ \ docker-compose.yml \ .env # ---- 3. Voicemail ----
echo "Backing up voicemail..."
if [ -d "/opt/asterisk-docker/data/voicemail" ] && [ "$(ls -A /opt/asterisk-docker/data/voicemail 2>/dev/null)" ]; then tar czf "$BACKUP_PATH/voicemail.tar.gz" \ -C /opt/asterisk-docker/data voicemail/ echo " Voicemail: $(du -sh "$BACKUP_PATH/voicemail.tar.gz" | cut -f1)"
else echo " Voicemail: empty, skipped"
fi # ---- 4. Certificates ----
echo "Backing up certificates..."
if [ -d "/opt/asterisk-docker/data/certs" ]; then tar czf "$BACKUP_PATH/certs.tar.gz" \ -C /opt/asterisk-docker/data certs/
fi # ---- 5. Recordings (optional - can be very large) ----
# Uncomment to include recordings in backup
# echo "Backing up recordings..."
# tar czf "$BACKUP_PATH/recordings.tar.gz" \
# -C /opt/asterisk-docker/data recordings/ # ---- 6. Create manifest ----
echo "Creating backup manifest..."
cat > "$BACKUP_PATH/manifest.txt" <<MANIFEST
Asterisk Docker Backup
Date: $(date)
Host: $(hostname)
Docker Compose Project: ${COMPOSE_PROJECT_NAME} Files:
$(ls -lh "$BACKUP_PATH/") Container Status:
$(docker compose -f /opt/asterisk-docker/docker-compose.yml ps 2>/dev/null) Asterisk Version:
$(docker exec asterisk asterisk -V 2>/dev/null || echo "not running") Database Size:
$(docker exec asterisk-mariadb mysql -u root -p"${DB_ROOT_PASSWORD}" -e "SELECT table_schema AS db, ROUND(SUM(data_length + index_length) / 1024 / 1024, 2) AS 'Size (MB)' FROM information_schema.tables GROUP BY table_schema;" 2>/dev/null || echo "not available")
MANIFEST # ---- 7. Compress entire backup ----
echo "Compressing backup..."
tar czf "$BACKUP_DIR/asterisk-backup-$DATE.tar.gz" -C "$BACKUP_DIR" "$DATE/"
rm -rf "$BACKUP_PATH"
echo "Final backup: $(du -sh "$BACKUP_DIR/asterisk-backup-$DATE.tar.gz" | cut -f1)" # ---- 8. Cleanup old backups ----
echo "Cleaning up backups older than $RETENTION_DAYS days..."
find "$BACKUP_DIR" -name "asterisk-backup-*.tar.gz" -mtime +${RETENTION_DAYS} -delete echo "=== Backup Complete ==="
#!/bin/bash
# =============================================================================
# Asterisk Docker Stack - Full Backup
# Backs up: MariaDB dump, recordings, voicemail, configs, certificates
# ============================================================================= set -e BACKUP_DIR="/opt/asterisk-docker/backups"
DATE=$(date '+%Y%m%d_%H%M%S')
BACKUP_PATH="$BACKUP_DIR/$DATE"
RETENTION_DAYS=30 # Source environment
set -a
source /opt/asterisk-docker/.env
set +a echo "=== Asterisk Backup Starting: $DATE ===" mkdir -p "$BACKUP_PATH" # ---- 1. Database dump ----
echo "Backing up MariaDB..."
docker exec asterisk-mariadb mysqldump \ -u root -p"${DB_ROOT_PASSWORD}" \ --all-databases \ --single-transaction \ --routines \ --triggers \ --events \ > "$BACKUP_PATH/mariadb-all-databases.sql"
echo " Database dump: $(du -sh "$BACKUP_PATH/mariadb-all-databases.sql" | cut -f1)" # ---- 2. Configuration files ----
echo "Backing up configuration..."
tar czf "$BACKUP_PATH/configs.tar.gz" \ -C /opt/asterisk-docker \ asterisk/configs/ \ nginx/ \ coturn/ \ docker-compose.yml \ .env # ---- 3. Voicemail ----
echo "Backing up voicemail..."
if [ -d "/opt/asterisk-docker/data/voicemail" ] && [ "$(ls -A /opt/asterisk-docker/data/voicemail 2>/dev/null)" ]; then tar czf "$BACKUP_PATH/voicemail.tar.gz" \ -C /opt/asterisk-docker/data voicemail/ echo " Voicemail: $(du -sh "$BACKUP_PATH/voicemail.tar.gz" | cut -f1)"
else echo " Voicemail: empty, skipped"
fi # ---- 4. Certificates ----
echo "Backing up certificates..."
if [ -d "/opt/asterisk-docker/data/certs" ]; then tar czf "$BACKUP_PATH/certs.tar.gz" \ -C /opt/asterisk-docker/data certs/
fi # ---- 5. Recordings (optional - can be very large) ----
# Uncomment to include recordings in backup
# echo "Backing up recordings..."
# tar czf "$BACKUP_PATH/recordings.tar.gz" \
# -C /opt/asterisk-docker/data recordings/ # ---- 6. Create manifest ----
echo "Creating backup manifest..."
cat > "$BACKUP_PATH/manifest.txt" <<MANIFEST
Asterisk Docker Backup
Date: $(date)
Host: $(hostname)
Docker Compose Project: ${COMPOSE_PROJECT_NAME} Files:
$(ls -lh "$BACKUP_PATH/") Container Status:
$(docker compose -f /opt/asterisk-docker/docker-compose.yml ps 2>/dev/null) Asterisk Version:
$(docker exec asterisk asterisk -V 2>/dev/null || echo "not running") Database Size:
$(docker exec asterisk-mariadb mysql -u root -p"${DB_ROOT_PASSWORD}" -e "SELECT table_schema AS db, ROUND(SUM(data_length + index_length) / 1024 / 1024, 2) AS 'Size (MB)' FROM information_schema.tables GROUP BY table_schema;" 2>/dev/null || echo "not available")
MANIFEST # ---- 7. Compress entire backup ----
echo "Compressing backup..."
tar czf "$BACKUP_DIR/asterisk-backup-$DATE.tar.gz" -C "$BACKUP_DIR" "$DATE/"
rm -rf "$BACKUP_PATH"
echo "Final backup: $(du -sh "$BACKUP_DIR/asterisk-backup-$DATE.tar.gz" | cut -f1)" # ---- 8. Cleanup old backups ----
echo "Cleaning up backups older than $RETENTION_DAYS days..."
find "$BACKUP_DIR" -name "asterisk-backup-*.tar.gz" -mtime +${RETENTION_DAYS} -delete echo "=== Backup Complete ==="
#!/bin/bash
# =============================================================================
# Asterisk Docker Stack - Full Backup
# Backs up: MariaDB dump, recordings, voicemail, configs, certificates
# ============================================================================= set -e BACKUP_DIR="/opt/asterisk-docker/backups"
DATE=$(date '+%Y%m%d_%H%M%S')
BACKUP_PATH="$BACKUP_DIR/$DATE"
RETENTION_DAYS=30 # Source environment
set -a
source /opt/asterisk-docker/.env
set +a echo "=== Asterisk Backup Starting: $DATE ===" mkdir -p "$BACKUP_PATH" # ---- 1. Database dump ----
echo "Backing up MariaDB..."
docker exec asterisk-mariadb mysqldump \ -u root -p"${DB_ROOT_PASSWORD}" \ --all-databases \ --single-transaction \ --routines \ --triggers \ --events \ > "$BACKUP_PATH/mariadb-all-databases.sql"
echo " Database dump: $(du -sh "$BACKUP_PATH/mariadb-all-databases.sql" | cut -f1)" # ---- 2. Configuration files ----
echo "Backing up configuration..."
tar czf "$BACKUP_PATH/configs.tar.gz" \ -C /opt/asterisk-docker \ asterisk/configs/ \ nginx/ \ coturn/ \ docker-compose.yml \ .env # ---- 3. Voicemail ----
echo "Backing up voicemail..."
if [ -d "/opt/asterisk-docker/data/voicemail" ] && [ "$(ls -A /opt/asterisk-docker/data/voicemail 2>/dev/null)" ]; then tar czf "$BACKUP_PATH/voicemail.tar.gz" \ -C /opt/asterisk-docker/data voicemail/ echo " Voicemail: $(du -sh "$BACKUP_PATH/voicemail.tar.gz" | cut -f1)"
else echo " Voicemail: empty, skipped"
fi # ---- 4. Certificates ----
echo "Backing up certificates..."
if [ -d "/opt/asterisk-docker/data/certs" ]; then tar czf "$BACKUP_PATH/certs.tar.gz" \ -C /opt/asterisk-docker/data certs/
fi # ---- 5. Recordings (optional - can be very large) ----
# Uncomment to include recordings in backup
# echo "Backing up recordings..."
# tar czf "$BACKUP_PATH/recordings.tar.gz" \
# -C /opt/asterisk-docker/data recordings/ # ---- 6. Create manifest ----
echo "Creating backup manifest..."
cat > "$BACKUP_PATH/manifest.txt" <<MANIFEST
Asterisk Docker Backup
Date: $(date)
Host: $(hostname)
Docker Compose Project: ${COMPOSE_PROJECT_NAME} Files:
$(ls -lh "$BACKUP_PATH/") Container Status:
$(docker compose -f /opt/asterisk-docker/docker-compose.yml ps 2>/dev/null) Asterisk Version:
$(docker exec asterisk asterisk -V 2>/dev/null || echo "not running") Database Size:
$(docker exec asterisk-mariadb mysql -u root -p"${DB_ROOT_PASSWORD}" -e "SELECT table_schema AS db, ROUND(SUM(data_length + index_length) / 1024 / 1024, 2) AS 'Size (MB)' FROM information_schema.tables GROUP BY table_schema;" 2>/dev/null || echo "not available")
MANIFEST # ---- 7. Compress entire backup ----
echo "Compressing backup..."
tar czf "$BACKUP_DIR/asterisk-backup-$DATE.tar.gz" -C "$BACKUP_DIR" "$DATE/"
rm -rf "$BACKUP_PATH"
echo "Final backup: $(du -sh "$BACKUP_DIR/asterisk-backup-$DATE.tar.gz" | cut -f1)" # ---- 8. Cleanup old backups ----
echo "Cleaning up backups older than $RETENTION_DAYS days..."
find "$BACKUP_DIR" -name "asterisk-backup-*.tar.gz" -mtime +${RETENTION_DAYS} -delete echo "=== Backup Complete ==="
chmod +x /opt/asterisk-docker/scripts/backup.sh # Run backup daily at 2 AM
(crontab -l 2>/dev/null; echo "0 2 * * * /opt/asterisk-docker/scripts/backup.sh >> /var/log/asterisk-backup.log 2>&1") | crontab -
chmod +x /opt/asterisk-docker/scripts/backup.sh # Run backup daily at 2 AM
(crontab -l 2>/dev/null; echo "0 2 * * * /opt/asterisk-docker/scripts/backup.sh >> /var/log/asterisk-backup.log 2>&1") | crontab -
chmod +x /opt/asterisk-docker/scripts/backup.sh # Run backup daily at 2 AM
(crontab -l 2>/dev/null; echo "0 2 * * * /opt/asterisk-docker/scripts/backup.sh >> /var/log/asterisk-backup.log 2>&1") | crontab -
#!/bin/bash
# =============================================================================
# Asterisk Docker Stack - Restore from Backup
# Usage: ./restore.sh /path/to/asterisk-backup-YYYYMMDD_HHMMSS.tar.gz
# ============================================================================= set -e BACKUP_FILE="$1"
RESTORE_DIR="/tmp/asterisk-restore-$$" if [ -z "$BACKUP_FILE" ] || [ ! -f "$BACKUP_FILE" ]; then echo "Usage: $0 /path/to/asterisk-backup-*.tar.gz" exit 1
fi # Source environment
set -a
source /opt/asterisk-docker/.env
set +a echo "=== Asterisk Restore Starting ==="
echo "Backup file: $BACKUP_FILE"
echo ""
echo "WARNING: This will overwrite current data. Press Ctrl+C to abort."
echo "Continuing in 10 seconds..."
sleep 10 # Extract backup
mkdir -p "$RESTORE_DIR"
tar xzf "$BACKUP_FILE" -C "$RESTORE_DIR"
BACKUP_DATE=$(ls "$RESTORE_DIR")
BACKUP_PATH="$RESTORE_DIR/$BACKUP_DATE" echo "Backup date: $BACKUP_DATE" # Stop services
echo "Stopping services..."
cd /opt/asterisk-docker
docker compose down # Restore database
if [ -f "$BACKUP_PATH/mariadb-all-databases.sql" ]; then echo "Restoring database..." docker compose up -d mariadb sleep 10 # Wait for MariaDB to be ready docker exec -i asterisk-mariadb mysql -u root -p"${DB_ROOT_PASSWORD}" \ < "$BACKUP_PATH/mariadb-all-databases.sql" echo " Database restored."
fi # Restore configs
if [ -f "$BACKUP_PATH/configs.tar.gz" ]; then echo "Restoring configurations..." tar xzf "$BACKUP_PATH/configs.tar.gz" -C /opt/asterisk-docker/ echo " Configs restored."
fi # Restore voicemail
if [ -f "$BACKUP_PATH/voicemail.tar.gz" ]; then echo "Restoring voicemail..." tar xzf "$BACKUP_PATH/voicemail.tar.gz" -C /opt/asterisk-docker/data/ echo " Voicemail restored."
fi # Restore certificates
if [ -f "$BACKUP_PATH/certs.tar.gz" ]; then echo "Restoring certificates..." tar xzf "$BACKUP_PATH/certs.tar.gz" -C /opt/asterisk-docker/data/ echo " Certificates restored."
fi # Start all services
echo "Starting all services..."
docker compose up -d # Cleanup
rm -rf "$RESTORE_DIR" echo "=== Restore Complete ==="
echo "Verify with: docker compose ps"
#!/bin/bash
# =============================================================================
# Asterisk Docker Stack - Restore from Backup
# Usage: ./restore.sh /path/to/asterisk-backup-YYYYMMDD_HHMMSS.tar.gz
# ============================================================================= set -e BACKUP_FILE="$1"
RESTORE_DIR="/tmp/asterisk-restore-$$" if [ -z "$BACKUP_FILE" ] || [ ! -f "$BACKUP_FILE" ]; then echo "Usage: $0 /path/to/asterisk-backup-*.tar.gz" exit 1
fi # Source environment
set -a
source /opt/asterisk-docker/.env
set +a echo "=== Asterisk Restore Starting ==="
echo "Backup file: $BACKUP_FILE"
echo ""
echo "WARNING: This will overwrite current data. Press Ctrl+C to abort."
echo "Continuing in 10 seconds..."
sleep 10 # Extract backup
mkdir -p "$RESTORE_DIR"
tar xzf "$BACKUP_FILE" -C "$RESTORE_DIR"
BACKUP_DATE=$(ls "$RESTORE_DIR")
BACKUP_PATH="$RESTORE_DIR/$BACKUP_DATE" echo "Backup date: $BACKUP_DATE" # Stop services
echo "Stopping services..."
cd /opt/asterisk-docker
docker compose down # Restore database
if [ -f "$BACKUP_PATH/mariadb-all-databases.sql" ]; then echo "Restoring database..." docker compose up -d mariadb sleep 10 # Wait for MariaDB to be ready docker exec -i asterisk-mariadb mysql -u root -p"${DB_ROOT_PASSWORD}" \ < "$BACKUP_PATH/mariadb-all-databases.sql" echo " Database restored."
fi # Restore configs
if [ -f "$BACKUP_PATH/configs.tar.gz" ]; then echo "Restoring configurations..." tar xzf "$BACKUP_PATH/configs.tar.gz" -C /opt/asterisk-docker/ echo " Configs restored."
fi # Restore voicemail
if [ -f "$BACKUP_PATH/voicemail.tar.gz" ]; then echo "Restoring voicemail..." tar xzf "$BACKUP_PATH/voicemail.tar.gz" -C /opt/asterisk-docker/data/ echo " Voicemail restored."
fi # Restore certificates
if [ -f "$BACKUP_PATH/certs.tar.gz" ]; then echo "Restoring certificates..." tar xzf "$BACKUP_PATH/certs.tar.gz" -C /opt/asterisk-docker/data/ echo " Certificates restored."
fi # Start all services
echo "Starting all services..."
docker compose up -d # Cleanup
rm -rf "$RESTORE_DIR" echo "=== Restore Complete ==="
echo "Verify with: docker compose ps"
#!/bin/bash
# =============================================================================
# Asterisk Docker Stack - Restore from Backup
# Usage: ./restore.sh /path/to/asterisk-backup-YYYYMMDD_HHMMSS.tar.gz
# ============================================================================= set -e BACKUP_FILE="$1"
RESTORE_DIR="/tmp/asterisk-restore-$$" if [ -z "$BACKUP_FILE" ] || [ ! -f "$BACKUP_FILE" ]; then echo "Usage: $0 /path/to/asterisk-backup-*.tar.gz" exit 1
fi # Source environment
set -a
source /opt/asterisk-docker/.env
set +a echo "=== Asterisk Restore Starting ==="
echo "Backup file: $BACKUP_FILE"
echo ""
echo "WARNING: This will overwrite current data. Press Ctrl+C to abort."
echo "Continuing in 10 seconds..."
sleep 10 # Extract backup
mkdir -p "$RESTORE_DIR"
tar xzf "$BACKUP_FILE" -C "$RESTORE_DIR"
BACKUP_DATE=$(ls "$RESTORE_DIR")
BACKUP_PATH="$RESTORE_DIR/$BACKUP_DATE" echo "Backup date: $BACKUP_DATE" # Stop services
echo "Stopping services..."
cd /opt/asterisk-docker
docker compose down # Restore database
if [ -f "$BACKUP_PATH/mariadb-all-databases.sql" ]; then echo "Restoring database..." docker compose up -d mariadb sleep 10 # Wait for MariaDB to be ready docker exec -i asterisk-mariadb mysql -u root -p"${DB_ROOT_PASSWORD}" \ < "$BACKUP_PATH/mariadb-all-databases.sql" echo " Database restored."
fi # Restore configs
if [ -f "$BACKUP_PATH/configs.tar.gz" ]; then echo "Restoring configurations..." tar xzf "$BACKUP_PATH/configs.tar.gz" -C /opt/asterisk-docker/ echo " Configs restored."
fi # Restore voicemail
if [ -f "$BACKUP_PATH/voicemail.tar.gz" ]; then echo "Restoring voicemail..." tar xzf "$BACKUP_PATH/voicemail.tar.gz" -C /opt/asterisk-docker/data/ echo " Voicemail restored."
fi # Restore certificates
if [ -f "$BACKUP_PATH/certs.tar.gz" ]; then echo "Restoring certificates..." tar xzf "$BACKUP_PATH/certs.tar.gz" -C /opt/asterisk-docker/data/ echo " Certificates restored."
fi # Start all services
echo "Starting all services..."
docker compose up -d # Cleanup
rm -rf "$RESTORE_DIR" echo "=== Restore Complete ==="
echo "Verify with: docker compose ps"
chmod +x /opt/asterisk-docker/scripts/restore.sh
chmod +x /opt/asterisk-docker/scripts/restore.sh
chmod +x /opt/asterisk-docker/scripts/restore.sh
-- =============================================================================
-- Asterisk Database Schema
-- Executed automatically on first MariaDB container start
-- ============================================================================= USE asterisk; -- ---- CDR Table ----
CREATE TABLE IF NOT EXISTS cdr ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, calldate DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00', clid VARCHAR(80) NOT NULL DEFAULT '', src VARCHAR(80) NOT NULL DEFAULT '', dst VARCHAR(80) NOT NULL DEFAULT '', dcontext VARCHAR(80) NOT NULL DEFAULT '', channel VARCHAR(80) NOT NULL DEFAULT '', dstchannel VARCHAR(80) NOT NULL DEFAULT '', lastapp VARCHAR(80) NOT NULL DEFAULT '', lastdata VARCHAR(80) NOT NULL DEFAULT '', duration INT NOT NULL DEFAULT 0, billsec INT NOT NULL DEFAULT 0, disposition VARCHAR(45) NOT NULL DEFAULT '', amaflags INT NOT NULL DEFAULT 0, accountcode VARCHAR(20) NOT NULL DEFAULT '', uniqueid VARCHAR(150) NOT NULL DEFAULT '', userfield VARCHAR(255) NOT NULL DEFAULT '', peeraccount VARCHAR(20) NOT NULL DEFAULT '', linkedid VARCHAR(150) NOT NULL DEFAULT '', sequence INT NOT NULL DEFAULT 0, PRIMARY KEY (id), INDEX idx_calldate (calldate), INDEX idx_dst (dst), INDEX idx_src (src), INDEX idx_uniqueid (uniqueid), INDEX idx_disposition (disposition), INDEX idx_accountcode (accountcode), INDEX idx_clid (clid(20))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- ---- PJSIP Realtime Tables ----
-- These allow you to manage SIP endpoints via database instead of config files -- Endpoints
CREATE TABLE IF NOT EXISTS ps_endpoints ( id VARCHAR(40) NOT NULL, transport VARCHAR(40) DEFAULT NULL, aors VARCHAR(200) DEFAULT NULL, auth VARCHAR(40) DEFAULT NULL, context VARCHAR(40) DEFAULT 'from-internal', disallow VARCHAR(200) DEFAULT 'all', allow VARCHAR(200) DEFAULT 'opus,g722,ulaw,alaw', direct_media ENUM('yes','no') DEFAULT 'no', connected_line_method VARCHAR(40) DEFAULT NULL, direct_media_method VARCHAR(40) DEFAULT NULL, direct_media_glare_mitigation VARCHAR(40) DEFAULT NULL, disable_direct_media_on_nat ENUM('yes','no') DEFAULT NULL, dtmf_mode VARCHAR(40) DEFAULT 'rfc4733', external_media_address VARCHAR(40) DEFAULT NULL, force_rport ENUM('yes','no') DEFAULT 'yes', ice_support ENUM('yes','no') DEFAULT 'no', identify_by VARCHAR(80) DEFAULT NULL, mailboxes VARCHAR(40) DEFAULT NULL, media_address VARCHAR(40) DEFAULT NULL, media_encryption VARCHAR(40) DEFAULT 'no', media_use_received_transport ENUM('yes','no') DEFAULT NULL, 100rel VARCHAR(40) DEFAULT NULL, outbound_auth VARCHAR(40) DEFAULT NULL, outbound_proxy VARCHAR(256) DEFAULT NULL, rewrite_contact ENUM('yes','no') DEFAULT 'yes', rtp_ipv6 ENUM('yes','no') DEFAULT NULL, rtp_symmetric ENUM('yes','no') DEFAULT 'yes', send_diversion ENUM('yes','no') DEFAULT NULL, send_pai ENUM('yes','no') DEFAULT 'yes', send_rpid ENUM('yes','no') DEFAULT 'yes', timers_min_se INT DEFAULT NULL, timers VARCHAR(40) DEFAULT NULL, timers_sess_expires INT DEFAULT NULL, callerid VARCHAR(40) DEFAULT NULL, callerid_privacy VARCHAR(40) DEFAULT NULL, callerid_tag VARCHAR(40) DEFAULT NULL, trust_id_inbound ENUM('yes','no') DEFAULT 'yes', trust_id_outbound ENUM('yes','no') DEFAULT NULL, use_ptime ENUM('yes','no') DEFAULT NULL, use_avpf ENUM('yes','no') DEFAULT NULL, force_avp ENUM('yes','no') DEFAULT NULL, media_encryption_optimistic ENUM('yes','no') DEFAULT NULL, inband_progress ENUM('yes','no') DEFAULT NULL, call_group VARCHAR(40) DEFAULT NULL, pickup_group VARCHAR(40) DEFAULT NULL, named_call_group VARCHAR(40) DEFAULT NULL, named_pickup_group VARCHAR(40) DEFAULT NULL, device_state_busy_at INT DEFAULT NULL, t38_udptl ENUM('yes','no') DEFAULT NULL, t38_udptl_ec VARCHAR(40) DEFAULT NULL, t38_udptl_maxdatagram INT DEFAULT NULL, fax_detect ENUM('yes','no') DEFAULT NULL, t38_udptl_nat ENUM('yes','no') DEFAULT NULL, t38_udptl_ipv6 ENUM('yes','no') DEFAULT NULL, tone_zone VARCHAR(40) DEFAULT NULL, language VARCHAR(40) DEFAULT NULL, one_touch_recording ENUM('yes','no') DEFAULT NULL, record_on_feature VARCHAR(40) DEFAULT NULL, record_off_feature VARCHAR(40) DEFAULT NULL, rtp_engine VARCHAR(40) DEFAULT NULL, allow_transfer ENUM('yes','no') DEFAULT NULL, allow_subscribe ENUM('yes','no') DEFAULT NULL, sdp_owner VARCHAR(40) DEFAULT NULL, sdp_session VARCHAR(40) DEFAULT NULL, tos_audio VARCHAR(10) DEFAULT NULL, tos_video VARCHAR(10) DEFAULT NULL, sub_min_expiry INT DEFAULT NULL, from_domain VARCHAR(40) DEFAULT NULL, from_user VARCHAR(40) DEFAULT NULL, mwi_from_user VARCHAR(40) DEFAULT NULL, dtls_verify VARCHAR(40) DEFAULT NULL, dtls_rekey VARCHAR(40) DEFAULT NULL, dtls_cert_file VARCHAR(200) DEFAULT NULL, dtls_private_key VARCHAR(200) DEFAULT NULL, dtls_cipher VARCHAR(200) DEFAULT NULL, dtls_ca_file VARCHAR(200) DEFAULT NULL, dtls_ca_path VARCHAR(200) DEFAULT NULL, dtls_setup VARCHAR(40) DEFAULT NULL, srtp_tag_32 ENUM('yes','no') DEFAULT NULL, webrtc ENUM('yes','no') DEFAULT 'no', PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Authentication
CREATE TABLE IF NOT EXISTS ps_auths ( id VARCHAR(40) NOT NULL, auth_type VARCHAR(40) DEFAULT 'userpass', nonce_lifetime INT DEFAULT NULL, md5_cred VARCHAR(40) DEFAULT NULL, password VARCHAR(80) DEFAULT NULL, realm VARCHAR(40) DEFAULT NULL, username VARCHAR(40) DEFAULT NULL, PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- AORs (Address of Record)
CREATE TABLE IF NOT EXISTS ps_aors ( id VARCHAR(40) NOT NULL, contact VARCHAR(255) DEFAULT NULL, default_expiration INT DEFAULT 300, mailboxes VARCHAR(80) DEFAULT NULL, max_contacts INT DEFAULT 3, minimum_expiration INT DEFAULT 60, remove_existing ENUM('yes','no') DEFAULT 'yes', qualify_frequency INT DEFAULT 60, qualify_timeout FLOAT DEFAULT 3.0, authenticate_qualify ENUM('yes','no') DEFAULT NULL, outbound_proxy VARCHAR(256) DEFAULT NULL, support_path ENUM('yes','no') DEFAULT NULL, PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Contacts (registered devices)
CREATE TABLE IF NOT EXISTS ps_contacts ( id VARCHAR(255) NOT NULL, uri VARCHAR(255) DEFAULT NULL, expiration_time BIGINT DEFAULT NULL, qualify_frequency INT DEFAULT NULL, outbound_proxy VARCHAR(256) DEFAULT NULL, path TEXT DEFAULT NULL, user_agent VARCHAR(255) DEFAULT NULL, qualify_timeout FLOAT DEFAULT NULL, reg_server VARCHAR(20) DEFAULT NULL, authenticate_qualify ENUM('yes','no') DEFAULT NULL, via_addr VARCHAR(40) DEFAULT NULL, via_port INT DEFAULT NULL, call_id VARCHAR(255) DEFAULT NULL, endpoint VARCHAR(40) DEFAULT NULL, prune_on_boot ENUM('yes','no') DEFAULT 'yes', PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Endpoint Identification by IP
CREATE TABLE IF NOT EXISTS ps_endpoint_id_ips ( id VARCHAR(40) NOT NULL, endpoint VARCHAR(40) DEFAULT NULL, match VARCHAR(80) DEFAULT NULL, srv_lookups ENUM('yes','no') DEFAULT NULL, match_header VARCHAR(255) DEFAULT NULL, PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Domain Aliases
CREATE TABLE IF NOT EXISTS ps_domain_aliases ( id VARCHAR(40) NOT NULL, domain VARCHAR(80) DEFAULT NULL, PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Registrations (outbound registration to SIP trunks)
CREATE TABLE IF NOT EXISTS ps_registrations ( id VARCHAR(40) NOT NULL, auth_rejection_permanent ENUM('yes','no') DEFAULT NULL, client_uri VARCHAR(255) DEFAULT NULL, contact_user VARCHAR(40) DEFAULT NULL, expiration INT DEFAULT NULL, max_retries INT DEFAULT NULL, outbound_auth VARCHAR(40) DEFAULT NULL, outbound_proxy VARCHAR(256) DEFAULT NULL, retry_interval INT DEFAULT NULL, forbidden_retry_interval INT DEFAULT NULL, server_uri VARCHAR(255) DEFAULT NULL, transport VARCHAR(40) DEFAULT NULL, support_path ENUM('yes','no') DEFAULT NULL, line ENUM('yes','no') DEFAULT NULL, endpoint VARCHAR(40) DEFAULT NULL, PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- ---- Voicemail Users Table ----
CREATE TABLE IF NOT EXISTS voicemail_users ( uniqueid INT UNSIGNED NOT NULL AUTO_INCREMENT, context VARCHAR(50) NOT NULL DEFAULT 'default', mailbox VARCHAR(20) NOT NULL, password VARCHAR(20) NOT NULL DEFAULT '1234', fullname VARCHAR(150) DEFAULT NULL, email VARCHAR(250) DEFAULT NULL, pager VARCHAR(250) DEFAULT NULL, tz VARCHAR(80) DEFAULT 'central', attach ENUM('yes','no') DEFAULT 'yes', saycid ENUM('yes','no') DEFAULT 'yes', dialout VARCHAR(10) DEFAULT NULL, callback VARCHAR(10) DEFAULT NULL, review ENUM('yes','no') DEFAULT 'no', operator ENUM('yes','no') DEFAULT 'no', envelope ENUM('yes','no') DEFAULT 'no', sayduration ENUM('yes','no') DEFAULT 'yes', saydurationm INT DEFAULT 1, sendvoicemail ENUM('yes','no') DEFAULT 'no', delete_vm ENUM('yes','no') DEFAULT 'no', nextaftercmd ENUM('yes','no') DEFAULT 'yes', forcename ENUM('yes','no') DEFAULT 'no', forcegreetings ENUM('yes','no') DEFAULT 'no', hidefromdir ENUM('yes','no') DEFAULT 'yes', stamp DATETIME DEFAULT NULL, PRIMARY KEY (uniqueid), INDEX idx_context_mailbox (context, mailbox)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- ---- Queue Tables (for call queues) ----
CREATE TABLE IF NOT EXISTS queue_members ( uniqueid INT UNSIGNED NOT NULL AUTO_INCREMENT, membername VARCHAR(40) DEFAULT NULL, queue_name VARCHAR(128) DEFAULT NULL, interface VARCHAR(128) DEFAULT NULL, penalty INT DEFAULT NULL, paused INT DEFAULT NULL, state_interface VARCHAR(128) DEFAULT NULL, PRIMARY KEY (uniqueid), UNIQUE INDEX idx_queue_interface (queue_name, interface)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS queue_rules ( rule_name VARCHAR(80) NOT NULL DEFAULT '', time VARCHAR(32) NOT NULL DEFAULT '0', min_penalty VARCHAR(32) NOT NULL DEFAULT '0', max_penalty VARCHAR(32) NOT NULL DEFAULT '0', INDEX idx_rule_name (rule_name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- ---- Seed sample data ----
-- Insert sample endpoints into realtime tables
INSERT IGNORE INTO ps_endpoints (id, transport, aors, auth, context, disallow, allow, direct_media, dtmf_mode, force_rport, rtp_symmetric, rewrite_contact, callerid, mailboxes, call_group, pickup_group)
VALUES ('1001', 'transport-udp', '1001', '1001', 'from-internal', 'all', 'opus,g722,ulaw,alaw', 'no', 'rfc4733', 'yes', 'yes', 'yes', '"Reception" <1001>', '1001@default', '1', '1'), ('1002', 'transport-udp', '1002', '1002', 'from-internal', 'all', 'opus,g722,ulaw,alaw', 'no', 'rfc4733', 'yes', 'yes', 'yes', '"Sales" <1002>', '1002@default', '1', '1'); INSERT IGNORE INTO ps_auths (id, auth_type, username, password, realm)
VALUES ('1001', 'userpass', '1001', 'changeme', 'YOUR_DOMAIN'), ('1002', 'userpass', '1002', 'changeme', 'YOUR_DOMAIN'); INSERT IGNORE INTO ps_aors (id, max_contacts, remove_existing, qualify_frequency, qualify_timeout, default_expiration)
VALUES ('1001', 3, 'yes', 60, 5.0, 300), ('1002', 3, 'yes', 60, 5.0, 300); -- Insert sample voicemail users
INSERT IGNORE INTO voicemail_users (context, mailbox, password, fullname, email)
VALUES ('default', '1001', '1234', 'Reception', '[email protected]'), ('default', '1002', '1234', 'Sales', '[email protected]');
-- =============================================================================
-- Asterisk Database Schema
-- Executed automatically on first MariaDB container start
-- ============================================================================= USE asterisk; -- ---- CDR Table ----
CREATE TABLE IF NOT EXISTS cdr ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, calldate DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00', clid VARCHAR(80) NOT NULL DEFAULT '', src VARCHAR(80) NOT NULL DEFAULT '', dst VARCHAR(80) NOT NULL DEFAULT '', dcontext VARCHAR(80) NOT NULL DEFAULT '', channel VARCHAR(80) NOT NULL DEFAULT '', dstchannel VARCHAR(80) NOT NULL DEFAULT '', lastapp VARCHAR(80) NOT NULL DEFAULT '', lastdata VARCHAR(80) NOT NULL DEFAULT '', duration INT NOT NULL DEFAULT 0, billsec INT NOT NULL DEFAULT 0, disposition VARCHAR(45) NOT NULL DEFAULT '', amaflags INT NOT NULL DEFAULT 0, accountcode VARCHAR(20) NOT NULL DEFAULT '', uniqueid VARCHAR(150) NOT NULL DEFAULT '', userfield VARCHAR(255) NOT NULL DEFAULT '', peeraccount VARCHAR(20) NOT NULL DEFAULT '', linkedid VARCHAR(150) NOT NULL DEFAULT '', sequence INT NOT NULL DEFAULT 0, PRIMARY KEY (id), INDEX idx_calldate (calldate), INDEX idx_dst (dst), INDEX idx_src (src), INDEX idx_uniqueid (uniqueid), INDEX idx_disposition (disposition), INDEX idx_accountcode (accountcode), INDEX idx_clid (clid(20))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- ---- PJSIP Realtime Tables ----
-- These allow you to manage SIP endpoints via database instead of config files -- Endpoints
CREATE TABLE IF NOT EXISTS ps_endpoints ( id VARCHAR(40) NOT NULL, transport VARCHAR(40) DEFAULT NULL, aors VARCHAR(200) DEFAULT NULL, auth VARCHAR(40) DEFAULT NULL, context VARCHAR(40) DEFAULT 'from-internal', disallow VARCHAR(200) DEFAULT 'all', allow VARCHAR(200) DEFAULT 'opus,g722,ulaw,alaw', direct_media ENUM('yes','no') DEFAULT 'no', connected_line_method VARCHAR(40) DEFAULT NULL, direct_media_method VARCHAR(40) DEFAULT NULL, direct_media_glare_mitigation VARCHAR(40) DEFAULT NULL, disable_direct_media_on_nat ENUM('yes','no') DEFAULT NULL, dtmf_mode VARCHAR(40) DEFAULT 'rfc4733', external_media_address VARCHAR(40) DEFAULT NULL, force_rport ENUM('yes','no') DEFAULT 'yes', ice_support ENUM('yes','no') DEFAULT 'no', identify_by VARCHAR(80) DEFAULT NULL, mailboxes VARCHAR(40) DEFAULT NULL, media_address VARCHAR(40) DEFAULT NULL, media_encryption VARCHAR(40) DEFAULT 'no', media_use_received_transport ENUM('yes','no') DEFAULT NULL, 100rel VARCHAR(40) DEFAULT NULL, outbound_auth VARCHAR(40) DEFAULT NULL, outbound_proxy VARCHAR(256) DEFAULT NULL, rewrite_contact ENUM('yes','no') DEFAULT 'yes', rtp_ipv6 ENUM('yes','no') DEFAULT NULL, rtp_symmetric ENUM('yes','no') DEFAULT 'yes', send_diversion ENUM('yes','no') DEFAULT NULL, send_pai ENUM('yes','no') DEFAULT 'yes', send_rpid ENUM('yes','no') DEFAULT 'yes', timers_min_se INT DEFAULT NULL, timers VARCHAR(40) DEFAULT NULL, timers_sess_expires INT DEFAULT NULL, callerid VARCHAR(40) DEFAULT NULL, callerid_privacy VARCHAR(40) DEFAULT NULL, callerid_tag VARCHAR(40) DEFAULT NULL, trust_id_inbound ENUM('yes','no') DEFAULT 'yes', trust_id_outbound ENUM('yes','no') DEFAULT NULL, use_ptime ENUM('yes','no') DEFAULT NULL, use_avpf ENUM('yes','no') DEFAULT NULL, force_avp ENUM('yes','no') DEFAULT NULL, media_encryption_optimistic ENUM('yes','no') DEFAULT NULL, inband_progress ENUM('yes','no') DEFAULT NULL, call_group VARCHAR(40) DEFAULT NULL, pickup_group VARCHAR(40) DEFAULT NULL, named_call_group VARCHAR(40) DEFAULT NULL, named_pickup_group VARCHAR(40) DEFAULT NULL, device_state_busy_at INT DEFAULT NULL, t38_udptl ENUM('yes','no') DEFAULT NULL, t38_udptl_ec VARCHAR(40) DEFAULT NULL, t38_udptl_maxdatagram INT DEFAULT NULL, fax_detect ENUM('yes','no') DEFAULT NULL, t38_udptl_nat ENUM('yes','no') DEFAULT NULL, t38_udptl_ipv6 ENUM('yes','no') DEFAULT NULL, tone_zone VARCHAR(40) DEFAULT NULL, language VARCHAR(40) DEFAULT NULL, one_touch_recording ENUM('yes','no') DEFAULT NULL, record_on_feature VARCHAR(40) DEFAULT NULL, record_off_feature VARCHAR(40) DEFAULT NULL, rtp_engine VARCHAR(40) DEFAULT NULL, allow_transfer ENUM('yes','no') DEFAULT NULL, allow_subscribe ENUM('yes','no') DEFAULT NULL, sdp_owner VARCHAR(40) DEFAULT NULL, sdp_session VARCHAR(40) DEFAULT NULL, tos_audio VARCHAR(10) DEFAULT NULL, tos_video VARCHAR(10) DEFAULT NULL, sub_min_expiry INT DEFAULT NULL, from_domain VARCHAR(40) DEFAULT NULL, from_user VARCHAR(40) DEFAULT NULL, mwi_from_user VARCHAR(40) DEFAULT NULL, dtls_verify VARCHAR(40) DEFAULT NULL, dtls_rekey VARCHAR(40) DEFAULT NULL, dtls_cert_file VARCHAR(200) DEFAULT NULL, dtls_private_key VARCHAR(200) DEFAULT NULL, dtls_cipher VARCHAR(200) DEFAULT NULL, dtls_ca_file VARCHAR(200) DEFAULT NULL, dtls_ca_path VARCHAR(200) DEFAULT NULL, dtls_setup VARCHAR(40) DEFAULT NULL, srtp_tag_32 ENUM('yes','no') DEFAULT NULL, webrtc ENUM('yes','no') DEFAULT 'no', PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Authentication
CREATE TABLE IF NOT EXISTS ps_auths ( id VARCHAR(40) NOT NULL, auth_type VARCHAR(40) DEFAULT 'userpass', nonce_lifetime INT DEFAULT NULL, md5_cred VARCHAR(40) DEFAULT NULL, password VARCHAR(80) DEFAULT NULL, realm VARCHAR(40) DEFAULT NULL, username VARCHAR(40) DEFAULT NULL, PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- AORs (Address of Record)
CREATE TABLE IF NOT EXISTS ps_aors ( id VARCHAR(40) NOT NULL, contact VARCHAR(255) DEFAULT NULL, default_expiration INT DEFAULT 300, mailboxes VARCHAR(80) DEFAULT NULL, max_contacts INT DEFAULT 3, minimum_expiration INT DEFAULT 60, remove_existing ENUM('yes','no') DEFAULT 'yes', qualify_frequency INT DEFAULT 60, qualify_timeout FLOAT DEFAULT 3.0, authenticate_qualify ENUM('yes','no') DEFAULT NULL, outbound_proxy VARCHAR(256) DEFAULT NULL, support_path ENUM('yes','no') DEFAULT NULL, PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Contacts (registered devices)
CREATE TABLE IF NOT EXISTS ps_contacts ( id VARCHAR(255) NOT NULL, uri VARCHAR(255) DEFAULT NULL, expiration_time BIGINT DEFAULT NULL, qualify_frequency INT DEFAULT NULL, outbound_proxy VARCHAR(256) DEFAULT NULL, path TEXT DEFAULT NULL, user_agent VARCHAR(255) DEFAULT NULL, qualify_timeout FLOAT DEFAULT NULL, reg_server VARCHAR(20) DEFAULT NULL, authenticate_qualify ENUM('yes','no') DEFAULT NULL, via_addr VARCHAR(40) DEFAULT NULL, via_port INT DEFAULT NULL, call_id VARCHAR(255) DEFAULT NULL, endpoint VARCHAR(40) DEFAULT NULL, prune_on_boot ENUM('yes','no') DEFAULT 'yes', PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Endpoint Identification by IP
CREATE TABLE IF NOT EXISTS ps_endpoint_id_ips ( id VARCHAR(40) NOT NULL, endpoint VARCHAR(40) DEFAULT NULL, match VARCHAR(80) DEFAULT NULL, srv_lookups ENUM('yes','no') DEFAULT NULL, match_header VARCHAR(255) DEFAULT NULL, PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Domain Aliases
CREATE TABLE IF NOT EXISTS ps_domain_aliases ( id VARCHAR(40) NOT NULL, domain VARCHAR(80) DEFAULT NULL, PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Registrations (outbound registration to SIP trunks)
CREATE TABLE IF NOT EXISTS ps_registrations ( id VARCHAR(40) NOT NULL, auth_rejection_permanent ENUM('yes','no') DEFAULT NULL, client_uri VARCHAR(255) DEFAULT NULL, contact_user VARCHAR(40) DEFAULT NULL, expiration INT DEFAULT NULL, max_retries INT DEFAULT NULL, outbound_auth VARCHAR(40) DEFAULT NULL, outbound_proxy VARCHAR(256) DEFAULT NULL, retry_interval INT DEFAULT NULL, forbidden_retry_interval INT DEFAULT NULL, server_uri VARCHAR(255) DEFAULT NULL, transport VARCHAR(40) DEFAULT NULL, support_path ENUM('yes','no') DEFAULT NULL, line ENUM('yes','no') DEFAULT NULL, endpoint VARCHAR(40) DEFAULT NULL, PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- ---- Voicemail Users Table ----
CREATE TABLE IF NOT EXISTS voicemail_users ( uniqueid INT UNSIGNED NOT NULL AUTO_INCREMENT, context VARCHAR(50) NOT NULL DEFAULT 'default', mailbox VARCHAR(20) NOT NULL, password VARCHAR(20) NOT NULL DEFAULT '1234', fullname VARCHAR(150) DEFAULT NULL, email VARCHAR(250) DEFAULT NULL, pager VARCHAR(250) DEFAULT NULL, tz VARCHAR(80) DEFAULT 'central', attach ENUM('yes','no') DEFAULT 'yes', saycid ENUM('yes','no') DEFAULT 'yes', dialout VARCHAR(10) DEFAULT NULL, callback VARCHAR(10) DEFAULT NULL, review ENUM('yes','no') DEFAULT 'no', operator ENUM('yes','no') DEFAULT 'no', envelope ENUM('yes','no') DEFAULT 'no', sayduration ENUM('yes','no') DEFAULT 'yes', saydurationm INT DEFAULT 1, sendvoicemail ENUM('yes','no') DEFAULT 'no', delete_vm ENUM('yes','no') DEFAULT 'no', nextaftercmd ENUM('yes','no') DEFAULT 'yes', forcename ENUM('yes','no') DEFAULT 'no', forcegreetings ENUM('yes','no') DEFAULT 'no', hidefromdir ENUM('yes','no') DEFAULT 'yes', stamp DATETIME DEFAULT NULL, PRIMARY KEY (uniqueid), INDEX idx_context_mailbox (context, mailbox)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- ---- Queue Tables (for call queues) ----
CREATE TABLE IF NOT EXISTS queue_members ( uniqueid INT UNSIGNED NOT NULL AUTO_INCREMENT, membername VARCHAR(40) DEFAULT NULL, queue_name VARCHAR(128) DEFAULT NULL, interface VARCHAR(128) DEFAULT NULL, penalty INT DEFAULT NULL, paused INT DEFAULT NULL, state_interface VARCHAR(128) DEFAULT NULL, PRIMARY KEY (uniqueid), UNIQUE INDEX idx_queue_interface (queue_name, interface)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS queue_rules ( rule_name VARCHAR(80) NOT NULL DEFAULT '', time VARCHAR(32) NOT NULL DEFAULT '0', min_penalty VARCHAR(32) NOT NULL DEFAULT '0', max_penalty VARCHAR(32) NOT NULL DEFAULT '0', INDEX idx_rule_name (rule_name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- ---- Seed sample data ----
-- Insert sample endpoints into realtime tables
INSERT IGNORE INTO ps_endpoints (id, transport, aors, auth, context, disallow, allow, direct_media, dtmf_mode, force_rport, rtp_symmetric, rewrite_contact, callerid, mailboxes, call_group, pickup_group)
VALUES ('1001', 'transport-udp', '1001', '1001', 'from-internal', 'all', 'opus,g722,ulaw,alaw', 'no', 'rfc4733', 'yes', 'yes', 'yes', '"Reception" <1001>', '1001@default', '1', '1'), ('1002', 'transport-udp', '1002', '1002', 'from-internal', 'all', 'opus,g722,ulaw,alaw', 'no', 'rfc4733', 'yes', 'yes', 'yes', '"Sales" <1002>', '1002@default', '1', '1'); INSERT IGNORE INTO ps_auths (id, auth_type, username, password, realm)
VALUES ('1001', 'userpass', '1001', 'changeme', 'YOUR_DOMAIN'), ('1002', 'userpass', '1002', 'changeme', 'YOUR_DOMAIN'); INSERT IGNORE INTO ps_aors (id, max_contacts, remove_existing, qualify_frequency, qualify_timeout, default_expiration)
VALUES ('1001', 3, 'yes', 60, 5.0, 300), ('1002', 3, 'yes', 60, 5.0, 300); -- Insert sample voicemail users
INSERT IGNORE INTO voicemail_users (context, mailbox, password, fullname, email)
VALUES ('default', '1001', '1234', 'Reception', '[email protected]'), ('default', '1002', '1234', 'Sales', '[email protected]');
-- =============================================================================
-- Asterisk Database Schema
-- Executed automatically on first MariaDB container start
-- ============================================================================= USE asterisk; -- ---- CDR Table ----
CREATE TABLE IF NOT EXISTS cdr ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, calldate DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00', clid VARCHAR(80) NOT NULL DEFAULT '', src VARCHAR(80) NOT NULL DEFAULT '', dst VARCHAR(80) NOT NULL DEFAULT '', dcontext VARCHAR(80) NOT NULL DEFAULT '', channel VARCHAR(80) NOT NULL DEFAULT '', dstchannel VARCHAR(80) NOT NULL DEFAULT '', lastapp VARCHAR(80) NOT NULL DEFAULT '', lastdata VARCHAR(80) NOT NULL DEFAULT '', duration INT NOT NULL DEFAULT 0, billsec INT NOT NULL DEFAULT 0, disposition VARCHAR(45) NOT NULL DEFAULT '', amaflags INT NOT NULL DEFAULT 0, accountcode VARCHAR(20) NOT NULL DEFAULT '', uniqueid VARCHAR(150) NOT NULL DEFAULT '', userfield VARCHAR(255) NOT NULL DEFAULT '', peeraccount VARCHAR(20) NOT NULL DEFAULT '', linkedid VARCHAR(150) NOT NULL DEFAULT '', sequence INT NOT NULL DEFAULT 0, PRIMARY KEY (id), INDEX idx_calldate (calldate), INDEX idx_dst (dst), INDEX idx_src (src), INDEX idx_uniqueid (uniqueid), INDEX idx_disposition (disposition), INDEX idx_accountcode (accountcode), INDEX idx_clid (clid(20))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- ---- PJSIP Realtime Tables ----
-- These allow you to manage SIP endpoints via database instead of config files -- Endpoints
CREATE TABLE IF NOT EXISTS ps_endpoints ( id VARCHAR(40) NOT NULL, transport VARCHAR(40) DEFAULT NULL, aors VARCHAR(200) DEFAULT NULL, auth VARCHAR(40) DEFAULT NULL, context VARCHAR(40) DEFAULT 'from-internal', disallow VARCHAR(200) DEFAULT 'all', allow VARCHAR(200) DEFAULT 'opus,g722,ulaw,alaw', direct_media ENUM('yes','no') DEFAULT 'no', connected_line_method VARCHAR(40) DEFAULT NULL, direct_media_method VARCHAR(40) DEFAULT NULL, direct_media_glare_mitigation VARCHAR(40) DEFAULT NULL, disable_direct_media_on_nat ENUM('yes','no') DEFAULT NULL, dtmf_mode VARCHAR(40) DEFAULT 'rfc4733', external_media_address VARCHAR(40) DEFAULT NULL, force_rport ENUM('yes','no') DEFAULT 'yes', ice_support ENUM('yes','no') DEFAULT 'no', identify_by VARCHAR(80) DEFAULT NULL, mailboxes VARCHAR(40) DEFAULT NULL, media_address VARCHAR(40) DEFAULT NULL, media_encryption VARCHAR(40) DEFAULT 'no', media_use_received_transport ENUM('yes','no') DEFAULT NULL, 100rel VARCHAR(40) DEFAULT NULL, outbound_auth VARCHAR(40) DEFAULT NULL, outbound_proxy VARCHAR(256) DEFAULT NULL, rewrite_contact ENUM('yes','no') DEFAULT 'yes', rtp_ipv6 ENUM('yes','no') DEFAULT NULL, rtp_symmetric ENUM('yes','no') DEFAULT 'yes', send_diversion ENUM('yes','no') DEFAULT NULL, send_pai ENUM('yes','no') DEFAULT 'yes', send_rpid ENUM('yes','no') DEFAULT 'yes', timers_min_se INT DEFAULT NULL, timers VARCHAR(40) DEFAULT NULL, timers_sess_expires INT DEFAULT NULL, callerid VARCHAR(40) DEFAULT NULL, callerid_privacy VARCHAR(40) DEFAULT NULL, callerid_tag VARCHAR(40) DEFAULT NULL, trust_id_inbound ENUM('yes','no') DEFAULT 'yes', trust_id_outbound ENUM('yes','no') DEFAULT NULL, use_ptime ENUM('yes','no') DEFAULT NULL, use_avpf ENUM('yes','no') DEFAULT NULL, force_avp ENUM('yes','no') DEFAULT NULL, media_encryption_optimistic ENUM('yes','no') DEFAULT NULL, inband_progress ENUM('yes','no') DEFAULT NULL, call_group VARCHAR(40) DEFAULT NULL, pickup_group VARCHAR(40) DEFAULT NULL, named_call_group VARCHAR(40) DEFAULT NULL, named_pickup_group VARCHAR(40) DEFAULT NULL, device_state_busy_at INT DEFAULT NULL, t38_udptl ENUM('yes','no') DEFAULT NULL, t38_udptl_ec VARCHAR(40) DEFAULT NULL, t38_udptl_maxdatagram INT DEFAULT NULL, fax_detect ENUM('yes','no') DEFAULT NULL, t38_udptl_nat ENUM('yes','no') DEFAULT NULL, t38_udptl_ipv6 ENUM('yes','no') DEFAULT NULL, tone_zone VARCHAR(40) DEFAULT NULL, language VARCHAR(40) DEFAULT NULL, one_touch_recording ENUM('yes','no') DEFAULT NULL, record_on_feature VARCHAR(40) DEFAULT NULL, record_off_feature VARCHAR(40) DEFAULT NULL, rtp_engine VARCHAR(40) DEFAULT NULL, allow_transfer ENUM('yes','no') DEFAULT NULL, allow_subscribe ENUM('yes','no') DEFAULT NULL, sdp_owner VARCHAR(40) DEFAULT NULL, sdp_session VARCHAR(40) DEFAULT NULL, tos_audio VARCHAR(10) DEFAULT NULL, tos_video VARCHAR(10) DEFAULT NULL, sub_min_expiry INT DEFAULT NULL, from_domain VARCHAR(40) DEFAULT NULL, from_user VARCHAR(40) DEFAULT NULL, mwi_from_user VARCHAR(40) DEFAULT NULL, dtls_verify VARCHAR(40) DEFAULT NULL, dtls_rekey VARCHAR(40) DEFAULT NULL, dtls_cert_file VARCHAR(200) DEFAULT NULL, dtls_private_key VARCHAR(200) DEFAULT NULL, dtls_cipher VARCHAR(200) DEFAULT NULL, dtls_ca_file VARCHAR(200) DEFAULT NULL, dtls_ca_path VARCHAR(200) DEFAULT NULL, dtls_setup VARCHAR(40) DEFAULT NULL, srtp_tag_32 ENUM('yes','no') DEFAULT NULL, webrtc ENUM('yes','no') DEFAULT 'no', PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Authentication
CREATE TABLE IF NOT EXISTS ps_auths ( id VARCHAR(40) NOT NULL, auth_type VARCHAR(40) DEFAULT 'userpass', nonce_lifetime INT DEFAULT NULL, md5_cred VARCHAR(40) DEFAULT NULL, password VARCHAR(80) DEFAULT NULL, realm VARCHAR(40) DEFAULT NULL, username VARCHAR(40) DEFAULT NULL, PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- AORs (Address of Record)
CREATE TABLE IF NOT EXISTS ps_aors ( id VARCHAR(40) NOT NULL, contact VARCHAR(255) DEFAULT NULL, default_expiration INT DEFAULT 300, mailboxes VARCHAR(80) DEFAULT NULL, max_contacts INT DEFAULT 3, minimum_expiration INT DEFAULT 60, remove_existing ENUM('yes','no') DEFAULT 'yes', qualify_frequency INT DEFAULT 60, qualify_timeout FLOAT DEFAULT 3.0, authenticate_qualify ENUM('yes','no') DEFAULT NULL, outbound_proxy VARCHAR(256) DEFAULT NULL, support_path ENUM('yes','no') DEFAULT NULL, PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Contacts (registered devices)
CREATE TABLE IF NOT EXISTS ps_contacts ( id VARCHAR(255) NOT NULL, uri VARCHAR(255) DEFAULT NULL, expiration_time BIGINT DEFAULT NULL, qualify_frequency INT DEFAULT NULL, outbound_proxy VARCHAR(256) DEFAULT NULL, path TEXT DEFAULT NULL, user_agent VARCHAR(255) DEFAULT NULL, qualify_timeout FLOAT DEFAULT NULL, reg_server VARCHAR(20) DEFAULT NULL, authenticate_qualify ENUM('yes','no') DEFAULT NULL, via_addr VARCHAR(40) DEFAULT NULL, via_port INT DEFAULT NULL, call_id VARCHAR(255) DEFAULT NULL, endpoint VARCHAR(40) DEFAULT NULL, prune_on_boot ENUM('yes','no') DEFAULT 'yes', PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Endpoint Identification by IP
CREATE TABLE IF NOT EXISTS ps_endpoint_id_ips ( id VARCHAR(40) NOT NULL, endpoint VARCHAR(40) DEFAULT NULL, match VARCHAR(80) DEFAULT NULL, srv_lookups ENUM('yes','no') DEFAULT NULL, match_header VARCHAR(255) DEFAULT NULL, PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Domain Aliases
CREATE TABLE IF NOT EXISTS ps_domain_aliases ( id VARCHAR(40) NOT NULL, domain VARCHAR(80) DEFAULT NULL, PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Registrations (outbound registration to SIP trunks)
CREATE TABLE IF NOT EXISTS ps_registrations ( id VARCHAR(40) NOT NULL, auth_rejection_permanent ENUM('yes','no') DEFAULT NULL, client_uri VARCHAR(255) DEFAULT NULL, contact_user VARCHAR(40) DEFAULT NULL, expiration INT DEFAULT NULL, max_retries INT DEFAULT NULL, outbound_auth VARCHAR(40) DEFAULT NULL, outbound_proxy VARCHAR(256) DEFAULT NULL, retry_interval INT DEFAULT NULL, forbidden_retry_interval INT DEFAULT NULL, server_uri VARCHAR(255) DEFAULT NULL, transport VARCHAR(40) DEFAULT NULL, support_path ENUM('yes','no') DEFAULT NULL, line ENUM('yes','no') DEFAULT NULL, endpoint VARCHAR(40) DEFAULT NULL, PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- ---- Voicemail Users Table ----
CREATE TABLE IF NOT EXISTS voicemail_users ( uniqueid INT UNSIGNED NOT NULL AUTO_INCREMENT, context VARCHAR(50) NOT NULL DEFAULT 'default', mailbox VARCHAR(20) NOT NULL, password VARCHAR(20) NOT NULL DEFAULT '1234', fullname VARCHAR(150) DEFAULT NULL, email VARCHAR(250) DEFAULT NULL, pager VARCHAR(250) DEFAULT NULL, tz VARCHAR(80) DEFAULT 'central', attach ENUM('yes','no') DEFAULT 'yes', saycid ENUM('yes','no') DEFAULT 'yes', dialout VARCHAR(10) DEFAULT NULL, callback VARCHAR(10) DEFAULT NULL, review ENUM('yes','no') DEFAULT 'no', operator ENUM('yes','no') DEFAULT 'no', envelope ENUM('yes','no') DEFAULT 'no', sayduration ENUM('yes','no') DEFAULT 'yes', saydurationm INT DEFAULT 1, sendvoicemail ENUM('yes','no') DEFAULT 'no', delete_vm ENUM('yes','no') DEFAULT 'no', nextaftercmd ENUM('yes','no') DEFAULT 'yes', forcename ENUM('yes','no') DEFAULT 'no', forcegreetings ENUM('yes','no') DEFAULT 'no', hidefromdir ENUM('yes','no') DEFAULT 'yes', stamp DATETIME DEFAULT NULL, PRIMARY KEY (uniqueid), INDEX idx_context_mailbox (context, mailbox)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- ---- Queue Tables (for call queues) ----
CREATE TABLE IF NOT EXISTS queue_members ( uniqueid INT UNSIGNED NOT NULL AUTO_INCREMENT, membername VARCHAR(40) DEFAULT NULL, queue_name VARCHAR(128) DEFAULT NULL, interface VARCHAR(128) DEFAULT NULL, penalty INT DEFAULT NULL, paused INT DEFAULT NULL, state_interface VARCHAR(128) DEFAULT NULL, PRIMARY KEY (uniqueid), UNIQUE INDEX idx_queue_interface (queue_name, interface)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS queue_rules ( rule_name VARCHAR(80) NOT NULL DEFAULT '', time VARCHAR(32) NOT NULL DEFAULT '0', min_penalty VARCHAR(32) NOT NULL DEFAULT '0', max_penalty VARCHAR(32) NOT NULL DEFAULT '0', INDEX idx_rule_name (rule_name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- ---- Seed sample data ----
-- Insert sample endpoints into realtime tables
INSERT IGNORE INTO ps_endpoints (id, transport, aors, auth, context, disallow, allow, direct_media, dtmf_mode, force_rport, rtp_symmetric, rewrite_contact, callerid, mailboxes, call_group, pickup_group)
VALUES ('1001', 'transport-udp', '1001', '1001', 'from-internal', 'all', 'opus,g722,ulaw,alaw', 'no', 'rfc4733', 'yes', 'yes', 'yes', '"Reception" <1001>', '1001@default', '1', '1'), ('1002', 'transport-udp', '1002', '1002', 'from-internal', 'all', 'opus,g722,ulaw,alaw', 'no', 'rfc4733', 'yes', 'yes', 'yes', '"Sales" <1002>', '1002@default', '1', '1'); INSERT IGNORE INTO ps_auths (id, auth_type, username, password, realm)
VALUES ('1001', 'userpass', '1001', 'changeme', 'YOUR_DOMAIN'), ('1002', 'userpass', '1002', 'changeme', 'YOUR_DOMAIN'); INSERT IGNORE INTO ps_aors (id, max_contacts, remove_existing, qualify_frequency, qualify_timeout, default_expiration)
VALUES ('1001', 3, 'yes', 60, 5.0, 300), ('1002', 3, 'yes', 60, 5.0, 300); -- Insert sample voicemail users
INSERT IGNORE INTO voicemail_users (context, mailbox, password, fullname, email)
VALUES ('default', '1001', '1234', 'Reception', '[email protected]'), ('default', '1002', '1234', 'Sales', '[email protected]');
; =============================================================================
; Sorcery Configuration - Maps PJSIP objects to database tables
; ============================================================================= [res_pjsip]
endpoint = realtime,ps_endpoints
auth = realtime,ps_auths
aor = realtime,ps_aors
domain_alias = realtime,ps_domain_aliases
contact = realtime,ps_contacts [res_pjsip_endpoint_identifier_ip]
identify = realtime,ps_endpoint_id_ips [res_pjsip_outbound_registration]
registration = realtime,ps_registrations
; =============================================================================
; Sorcery Configuration - Maps PJSIP objects to database tables
; ============================================================================= [res_pjsip]
endpoint = realtime,ps_endpoints
auth = realtime,ps_auths
aor = realtime,ps_aors
domain_alias = realtime,ps_domain_aliases
contact = realtime,ps_contacts [res_pjsip_endpoint_identifier_ip]
identify = realtime,ps_endpoint_id_ips [res_pjsip_outbound_registration]
registration = realtime,ps_registrations
; =============================================================================
; Sorcery Configuration - Maps PJSIP objects to database tables
; ============================================================================= [res_pjsip]
endpoint = realtime,ps_endpoints
auth = realtime,ps_auths
aor = realtime,ps_aors
domain_alias = realtime,ps_domain_aliases
contact = realtime,ps_contacts [res_pjsip_endpoint_identifier_ip]
identify = realtime,ps_endpoint_id_ips [res_pjsip_outbound_registration]
registration = realtime,ps_registrations
; =============================================================================
; External Configuration - Maps Asterisk subsystems to ODBC
; ============================================================================= [settings]
ps_endpoints => odbc,asterisk-connector,ps_endpoints
ps_auths => odbc,asterisk-connector,ps_auths
ps_aors => odbc,asterisk-connector,ps_aors
ps_contacts => odbc,asterisk-connector,ps_contacts
ps_domain_aliases => odbc,asterisk-connector,ps_domain_aliases
ps_endpoint_id_ips => odbc,asterisk-connector,ps_endpoint_id_ips
ps_registrations => odbc,asterisk-connector,ps_registrations
voicemail => odbc,asterisk-connector,voicemail_users
queue_members => odbc,asterisk-connector,queue_members
queue_rules => odbc,asterisk-connector,queue_rules
; =============================================================================
; External Configuration - Maps Asterisk subsystems to ODBC
; ============================================================================= [settings]
ps_endpoints => odbc,asterisk-connector,ps_endpoints
ps_auths => odbc,asterisk-connector,ps_auths
ps_aors => odbc,asterisk-connector,ps_aors
ps_contacts => odbc,asterisk-connector,ps_contacts
ps_domain_aliases => odbc,asterisk-connector,ps_domain_aliases
ps_endpoint_id_ips => odbc,asterisk-connector,ps_endpoint_id_ips
ps_registrations => odbc,asterisk-connector,ps_registrations
voicemail => odbc,asterisk-connector,voicemail_users
queue_members => odbc,asterisk-connector,queue_members
queue_rules => odbc,asterisk-connector,queue_rules
; =============================================================================
; External Configuration - Maps Asterisk subsystems to ODBC
; ============================================================================= [settings]
ps_endpoints => odbc,asterisk-connector,ps_endpoints
ps_auths => odbc,asterisk-connector,ps_auths
ps_aors => odbc,asterisk-connector,ps_aors
ps_contacts => odbc,asterisk-connector,ps_contacts
ps_domain_aliases => odbc,asterisk-connector,ps_domain_aliases
ps_endpoint_id_ips => odbc,asterisk-connector,ps_endpoint_id_ips
ps_registrations => odbc,asterisk-connector,ps_registrations
voicemail => odbc,asterisk-connector,voicemail_users
queue_members => odbc,asterisk-connector,queue_members
queue_rules => odbc,asterisk-connector,queue_rules
-- Add a new extension
INSERT INTO ps_endpoints (id, transport, aors, auth, context, disallow, allow, direct_media, dtmf_mode, force_rport, rtp_symmetric, rewrite_contact, callerid, mailboxes)
VALUES ('1003', 'transport-udp', '1003', '1003', 'from-internal', 'all', 'opus,g722,ulaw,alaw', 'no', 'rfc4733', 'yes', 'yes', 'yes', '"Support" <1003>', '1003@default'); INSERT INTO ps_auths (id, auth_type, username, password, realm)
VALUES ('1003', 'userpass', '1003', 'SecurePass123!', 'YOUR_DOMAIN'); INSERT INTO ps_aors (id, max_contacts, remove_existing, qualify_frequency)
VALUES ('1003', 3, 'yes', 60); -- The endpoint is immediately available - no reload needed!
-- Verify from Asterisk CLI:
-- asterisk -rx "pjsip show endpoint 1003"
-- Add a new extension
INSERT INTO ps_endpoints (id, transport, aors, auth, context, disallow, allow, direct_media, dtmf_mode, force_rport, rtp_symmetric, rewrite_contact, callerid, mailboxes)
VALUES ('1003', 'transport-udp', '1003', '1003', 'from-internal', 'all', 'opus,g722,ulaw,alaw', 'no', 'rfc4733', 'yes', 'yes', 'yes', '"Support" <1003>', '1003@default'); INSERT INTO ps_auths (id, auth_type, username, password, realm)
VALUES ('1003', 'userpass', '1003', 'SecurePass123!', 'YOUR_DOMAIN'); INSERT INTO ps_aors (id, max_contacts, remove_existing, qualify_frequency)
VALUES ('1003', 3, 'yes', 60); -- The endpoint is immediately available - no reload needed!
-- Verify from Asterisk CLI:
-- asterisk -rx "pjsip show endpoint 1003"
-- Add a new extension
INSERT INTO ps_endpoints (id, transport, aors, auth, context, disallow, allow, direct_media, dtmf_mode, force_rport, rtp_symmetric, rewrite_contact, callerid, mailboxes)
VALUES ('1003', 'transport-udp', '1003', '1003', 'from-internal', 'all', 'opus,g722,ulaw,alaw', 'no', 'rfc4733', 'yes', 'yes', 'yes', '"Support" <1003>', '1003@default'); INSERT INTO ps_auths (id, auth_type, username, password, realm)
VALUES ('1003', 'userpass', '1003', 'SecurePass123!', 'YOUR_DOMAIN'); INSERT INTO ps_aors (id, max_contacts, remove_existing, qualify_frequency)
VALUES ('1003', 3, 'yes', 60); -- The endpoint is immediately available - no reload needed!
-- Verify from Asterisk CLI:
-- asterisk -rx "pjsip show endpoint 1003"
# From the Docker host, you can manage endpoints via:
docker exec asterisk-mariadb mysql -u asterisk -p'Ast3r1sk_DB_2026!' asterisk \ -e "SELECT id, context, callerid FROM ps_endpoints;"
# From the Docker host, you can manage endpoints via:
docker exec asterisk-mariadb mysql -u asterisk -p'Ast3r1sk_DB_2026!' asterisk \ -e "SELECT id, context, callerid FROM ps_endpoints;"
# From the Docker host, you can manage endpoints via:
docker exec asterisk-mariadb mysql -u asterisk -p'Ast3r1sk_DB_2026!' asterisk \ -e "SELECT id, context, callerid FROM ps_endpoints;"
[asterisk-connector]
enabled = yes
dsn = asterisk-connector
username = ${DB_USER}
password = ${DB_PASSWORD}
pre-connect = yes ; Establish connections at startup
max_connections = 20 ; Pool size (increase for busy systems)
connect_timeout = 5 ; Seconds before connection attempt fails
negative_connection_cache = 600 ; Cache failed connection for 10 min
sanitysql = SELECT 1 ; Keep-alive query
[asterisk-connector]
enabled = yes
dsn = asterisk-connector
username = ${DB_USER}
password = ${DB_PASSWORD}
pre-connect = yes ; Establish connections at startup
max_connections = 20 ; Pool size (increase for busy systems)
connect_timeout = 5 ; Seconds before connection attempt fails
negative_connection_cache = 600 ; Cache failed connection for 10 min
sanitysql = SELECT 1 ; Keep-alive query
[asterisk-connector]
enabled = yes
dsn = asterisk-connector
username = ${DB_USER}
password = ${DB_PASSWORD}
pre-connect = yes ; Establish connections at startup
max_connections = 20 ; Pool size (increase for busy systems)
connect_timeout = 5 ; Seconds before connection attempt fails
negative_connection_cache = 600 ; Cache failed connection for 10 min
sanitysql = SELECT 1 ; Keep-alive query
docker compose exec asterisk asterisk -rx "odbc show all"
# Name: asterisk-connector
# DSN: asterisk-connector
# Last connection attempt: 2026-01-15 10:30:00
# Pooled: Yes
# Connected: Yes (20 connections)
docker compose exec asterisk asterisk -rx "odbc show all"
# Name: asterisk-connector
# DSN: asterisk-connector
# Last connection attempt: 2026-01-15 10:30:00
# Pooled: Yes
# Connected: Yes (20 connections)
docker compose exec asterisk asterisk -rx "odbc show all"
# Name: asterisk-connector
# DSN: asterisk-connector
# Last connection attempt: 2026-01-15 10:30:00
# Pooled: Yes
# Connected: Yes (20 connections)
┌─────────────────┐ REST API ┌──────────────────┐
│ Your App │◄──────────────────►│ Asterisk ARI │
│ (Python/Node) │ WebSocket │ Port 8088 │
│ │◄──────────────────►│ │
└─────────────────┘ └──────────────────┘ REST API: POST /ari/channels → Originate a call POST /ari/channels/{id}/answer → Answer a call POST /ari/channels/{id}/play → Play audio POST /ari/bridges → Create a bridge (conference) POST /ari/bridges/{id}/addChannel → Add channel to bridge DELETE /ari/channels/{id} → Hang up WebSocket (/ari/events): StasisStart → Call entered your application StasisEnd → Call left your application ChannelDtmfReceived → User pressed a key ChannelStateChange → Channel state changed PlaybackFinished → Audio playback completed
┌─────────────────┐ REST API ┌──────────────────┐
│ Your App │◄──────────────────►│ Asterisk ARI │
│ (Python/Node) │ WebSocket │ Port 8088 │
│ │◄──────────────────►│ │
└─────────────────┘ └──────────────────┘ REST API: POST /ari/channels → Originate a call POST /ari/channels/{id}/answer → Answer a call POST /ari/channels/{id}/play → Play audio POST /ari/bridges → Create a bridge (conference) POST /ari/bridges/{id}/addChannel → Add channel to bridge DELETE /ari/channels/{id} → Hang up WebSocket (/ari/events): StasisStart → Call entered your application StasisEnd → Call left your application ChannelDtmfReceived → User pressed a key ChannelStateChange → Channel state changed PlaybackFinished → Audio playback completed
┌─────────────────┐ REST API ┌──────────────────┐
│ Your App │◄──────────────────►│ Asterisk ARI │
│ (Python/Node) │ WebSocket │ Port 8088 │
│ │◄──────────────────►│ │
└─────────────────┘ └──────────────────┘ REST API: POST /ari/channels → Originate a call POST /ari/channels/{id}/answer → Answer a call POST /ari/channels/{id}/play → Play audio POST /ari/bridges → Create a bridge (conference) POST /ari/bridges/{id}/addChannel → Add channel to bridge DELETE /ari/channels/{id} → Hang up WebSocket (/ari/events): StasisStart → Call entered your application StasisEnd → Call left your application ChannelDtmfReceived → User pressed a key ChannelStateChange → Channel state changed PlaybackFinished → Audio playback completed
requests>=2.31.0
websocket-client>=1.7.0
redis>=5.0.0
requests>=2.31.0
websocket-client>=1.7.0
redis>=5.0.0
requests>=2.31.0
websocket-client>=1.7.0
redis>=5.0.0
#!/usr/bin/env python3
"""
Asterisk ARI Auto-Attendant Application
Handles inbound calls via REST API + WebSocket events. Requires: - Asterisk with ARI enabled (http.conf + ari.conf) - Dialplan: exten => _X.,1,Stasis(autoattendant,${EXTEN}) - pip install requests websocket-client redis
""" import json
import logging
import os
import sys
import threading
import time
from typing import Any import redis
import requests
import websocket # =============================================================================
# Configuration
# ============================================================================= ARI_URL = os.getenv("ARI_URL", "http://asterisk:8088")
ARI_USER = os.getenv("ARI_USERNAME", "ari_user")
ARI_PASS = os.getenv("ARI_PASSWORD", "Ar1_S3cur3_2026!")
ARI_APP = os.getenv("ARI_APP", "autoattendant") REDIS_HOST = os.getenv("REDIS_HOST", "redis")
REDIS_PORT = int(os.getenv("REDIS_PORT", "6379"))
REDIS_PASS = os.getenv("REDIS_PASSWORD", "R3d1s_S3cur3_2026!") # Logging
logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s", datefmt="%Y-%m-%d %H:%M:%S",
)
log = logging.getLogger("ari-autoattendant") # =============================================================================
# Redis Client (for state tracking)
# ============================================================================= redis_client = redis.Redis( host=REDIS_HOST, port=REDIS_PORT, password=REDIS_PASS, decode_responses=True,
) # =============================================================================
# ARI REST Client
# ============================================================================= class ARIClient: """Thin wrapper around the ARI REST API.""" def __init__(self, base_url: str, username: str, password: str): self.base_url = base_url.rstrip("/") self.auth = (username, password) self.session = requests.Session() self.session.auth = self.auth def get(self, path: str, **kwargs) -> Any: resp = self.session.get(f"{self.base_url}/ari{path}", **kwargs) resp.raise_for_status() return resp.json() if resp.content else None def post(self, path: str, **kwargs) -> Any: resp = self.session.post(f"{self.base_url}/ari{path}", **kwargs) resp.raise_for_status() return resp.json() if resp.content else None def delete(self, path: str, **kwargs) -> Any: resp = self.session.delete(f"{self.base_url}/ari{path}", **kwargs) resp.raise_for_status() return resp.json() if resp.content else None # ---- Channel Operations ---- def answer(self, channel_id: str): """Answer a channel.""" return self.post(f"/channels/{channel_id}/answer") def hangup(self, channel_id: str, reason: str = "normal"): """Hang up a channel.""" return self.delete(f"/channels/{channel_id}", params={"reason": reason}) def play(self, channel_id: str, media: str, playback_id: str = None): """Play media on a channel. Media format: 'sound:filename' or 'tone:dial'.""" params = {"media": media} if playback_id: params["playbackId"] = playback_id return self.post(f"/channels/{channel_id}/play", params=params) def dial(self, channel_id: str, endpoint: str, context: str = None, caller_id: str = None, timeout: int = 30): """Originate a call and bridge it.""" params = { "endpoint": endpoint, "app": ARI_APP, "timeout": timeout, } if caller_id: params["callerId"] = caller_id return self.post("/channels", params=params) # ---- Bridge Operations ---- def create_bridge(self, bridge_type: str = "mixing", name: str = None): """Create a bridge (conference).""" params = {"type": bridge_type} if name: params["name"] = name return self.post("/bridges", params=params) def add_to_bridge(self, bridge_id: str, channel_id: str): """Add a channel to a bridge.""" return self.post(f"/bridges/{bridge_id}/addChannel", params={"channel": channel_id}) def remove_from_bridge(self, bridge_id: str, channel_id: str): """Remove a channel from a bridge.""" return self.post(f"/bridges/{bridge_id}/removeChannel", params={"channel": channel_id}) def destroy_bridge(self, bridge_id: str): """Destroy a bridge.""" return self.delete(f"/bridges/{bridge_id}") # =============================================================================
# Call Handler
# ============================================================================= class CallHandler: """Handles a single call through the IVR flow.""" # IVR menu options: DTMF digit -> (description, PJSIP endpoint to dial) MENU = { "1": ("Sales", "PJSIP/1001"), "2": ("Support", "PJSIP/1004"), "3": ("Directory", None), # Sub-menu "0": ("Operator", "PJSIP/1001"), } def __init__(self, ari: ARIClient, channel_id: str, caller_info: dict): self.ari = ari self.channel_id = channel_id self.caller_info = caller_info self.state = "greeting" self.dtmf_buffer = "" self.attempts = 0 self.max_attempts = 3 self.bridge_id = None # Track state in Redis redis_client.hset(f"call:{channel_id}", mapping={ "state": self.state, "caller_num": caller_info.get("number", "unknown"), "caller_name": caller_info.get("name", "unknown"), "start_time": str(int(time.time())), }) def start(self): """Begin the IVR flow.""" log.info(f"Call {self.channel_id}: answering from {self.caller_info}") self.ari.answer(self.channel_id) time.sleep(0.5) # Brief pause after answer self._play_greeting() def _play_greeting(self): """Play the main greeting.""" self.state = "greeting" self._update_state() # Play greeting - uses built-in Asterisk sounds # In production, replace with custom recordings self.ari.play(self.channel_id, "sound:custom/welcome", playback_id=f"greeting-{self.channel_id}") def handle_dtmf(self, digit: str): """Process a DTMF digit.""" log.info(f"Call {self.channel_id}: DTMF '{digit}' in state '{self.state}'") if self.state in ("greeting", "waiting"): self._process_menu_selection(digit) elif self.state == "directory": self._process_directory_input(digit) def _process_menu_selection(self, digit: str): """Handle main menu DTMF selection.""" if digit in self.MENU: description, endpoint = self.MENU[digit] log.info(f"Call {self.channel_id}: selected '{description}'") if endpoint: self._transfer_to(endpoint, description) elif digit == "3": self.state = "directory" self._update_state() self.ari.play(self.channel_id, "sound:dir-pls-enter-person", playback_id=f"directory-{self.channel_id}") elif digit == "#": # Repeat menu self._play_greeting() else: self.ari.play(self.channel_id, "sound:option-is-invalid", playback_id=f"invalid-{self.channel_id}") self.attempts += 1 if self.attempts >= self.max_attempts: log.info(f"Call {self.channel_id}: max attempts, routing to operator") self._transfer_to("PJSIP/1001", "Operator (timeout)") def _transfer_to(self, endpoint: str, description: str): """Transfer the caller to an endpoint via a bridge.""" self.state = "transferring" self._update_state() try: # Create a bridge bridge = self.ari.create_bridge("mixing", f"transfer-{self.channel_id}") self.bridge_id = bridge["id"] # Add the caller to the bridge self.ari.add_to_bridge(self.bridge_id, self.channel_id) # Play ringing tone to caller self.ari.play(self.channel_id, "tone:ring", playback_id=f"ring-{self.channel_id}") # Originate a call to the target target = self.ari.dial(self.channel_id, endpoint, caller_id=self.caller_info.get("number", "")) log.info(f"Call {self.channel_id}: dialing {endpoint} " f"(bridge: {self.bridge_id})") # Store transfer info in Redis redis_client.hset(f"call:{self.channel_id}", mapping={ "state": "transferring", "transfer_to": description, "bridge_id": self.bridge_id, }) except Exception as e: log.error(f"Call {self.channel_id}: transfer failed: {e}") self.ari.play(self.channel_id, "sound:an-error-has-occurred", playback_id=f"error-{self.channel_id}") def _process_directory_input(self, digit: str): """Handle directory (spell-by-name) input.""" if digit == "*": # Go back to main menu self.state = "greeting" self._play_greeting() return self.dtmf_buffer += digit if len(self.dtmf_buffer) >= 3: # Look up by extension (simplified - real implementation would # search by name using the keypad mapping) ext = self.dtmf_buffer[:4] self.dtmf_buffer = "" log.info(f"Call {self.channel_id}: directory lookup for {ext}") self._transfer_to(f"PJSIP/{ext}", f"Directory: {ext}") def handle_playback_finished(self, playback_id: str): """Handle playback completion.""" if playback_id.startswith("greeting-"): self.state = "waiting" self._update_state() # Wait for DTMF, replay after timeout handled by dialplan def cleanup(self): """Clean up when the call ends.""" log.info(f"Call {self.channel_id}: cleanup") if self.bridge_id: try: self.ari.destroy_bridge(self.bridge_id) except Exception: pass redis_client.delete(f"call:{self.channel_id}") def _update_state(self): """Update call state in Redis.""" redis_client.hset(f"call:{self.channel_id}", "state", self.state) # =============================================================================
# WebSocket Event Handler
# ============================================================================= # Active call handlers
calls: dict[str, CallHandler] = {}
ari = ARIClient(ARI_URL, ARI_USER, ARI_PASS) def on_message(ws, message): """Handle incoming WebSocket events from Asterisk.""" try: event = json.loads(message) event_type = event.get("type", "unknown") channel = event.get("channel", {}) channel_id = channel.get("id", "") if event_type == "StasisStart": # New call entered our application caller = channel.get("caller", {}) caller_info = { "number": caller.get("number", "unknown"), "name": caller.get("name", "unknown"), } log.info(f"StasisStart: {channel_id} from {caller_info}") handler = CallHandler(ari, channel_id, caller_info) calls[channel_id] = handler # Start IVR in a separate thread to avoid blocking the event loop threading.Thread(target=handler.start, daemon=True).start() elif event_type == "StasisEnd": # Call left our application log.info(f"StasisEnd: {channel_id}") handler = calls.pop(channel_id, None) if handler: handler.cleanup() elif event_type == "ChannelDtmfReceived": # DTMF digit received digit = event.get("digit", "") handler = calls.get(channel_id) if handler: handler.handle_dtmf(digit) elif event_type == "PlaybackFinished": # Audio playback completed playback = event.get("playback", {}) playback_id = playback.get("id", "") # Find which call this playback belongs to target_channel = playback.get("target_uri", "").replace("channel:", "") handler = calls.get(target_channel) if handler: handler.handle_playback_finished(playback_id) elif event_type == "ChannelDestroyed": # Channel was destroyed (hangup) handler = calls.pop(channel_id, None) if handler: handler.cleanup() except Exception as e: log.error(f"Error handling event: {e}", exc_info=True) def on_error(ws, error): log.error(f"WebSocket error: {error}") def on_close(ws, close_status_code, close_msg): log.warning(f"WebSocket closed: {close_status_code} - {close_msg}") def on_open(ws): log.info("WebSocket connected to Asterisk ARI") # =============================================================================
# Main
# ============================================================================= def main(): """Connect to ARI WebSocket and handle events.""" log.info(f"Starting ARI Auto-Attendant") log.info(f"ARI URL: {ARI_URL}") log.info(f"Application: {ARI_APP}") # Verify ARI is reachable try: info = ari.get("/asterisk/info") log.info(f"Connected to Asterisk {info.get('system', {}).get('version', 'unknown')}") except Exception as e: log.error(f"Cannot connect to ARI: {e}") sys.exit(1) # Build WebSocket URL ws_url = ARI_URL.replace("http://", "ws://").replace("https://", "wss://") ws_url = f"{ws_url}/ari/events?api_key={ARI_USER}:{ARI_PASS}&app={ARI_APP}" # Connect with auto-reconnect while True: try: ws = websocket.WebSocketApp( ws_url, on_open=on_open, on_message=on_message, on_error=on_error, on_close=on_close, ) ws.run_forever(ping_interval=30, ping_timeout=10) except KeyboardInterrupt: log.info("Shutting down...") break except Exception as e: log.error(f"WebSocket connection failed: {e}") log.info("Reconnecting in 5 seconds...") time.sleep(5) if __name__ == "__main__": main()
#!/usr/bin/env python3
"""
Asterisk ARI Auto-Attendant Application
Handles inbound calls via REST API + WebSocket events. Requires: - Asterisk with ARI enabled (http.conf + ari.conf) - Dialplan: exten => _X.,1,Stasis(autoattendant,${EXTEN}) - pip install requests websocket-client redis
""" import json
import logging
import os
import sys
import threading
import time
from typing import Any import redis
import requests
import websocket # =============================================================================
# Configuration
# ============================================================================= ARI_URL = os.getenv("ARI_URL", "http://asterisk:8088")
ARI_USER = os.getenv("ARI_USERNAME", "ari_user")
ARI_PASS = os.getenv("ARI_PASSWORD", "Ar1_S3cur3_2026!")
ARI_APP = os.getenv("ARI_APP", "autoattendant") REDIS_HOST = os.getenv("REDIS_HOST", "redis")
REDIS_PORT = int(os.getenv("REDIS_PORT", "6379"))
REDIS_PASS = os.getenv("REDIS_PASSWORD", "R3d1s_S3cur3_2026!") # Logging
logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s", datefmt="%Y-%m-%d %H:%M:%S",
)
log = logging.getLogger("ari-autoattendant") # =============================================================================
# Redis Client (for state tracking)
# ============================================================================= redis_client = redis.Redis( host=REDIS_HOST, port=REDIS_PORT, password=REDIS_PASS, decode_responses=True,
) # =============================================================================
# ARI REST Client
# ============================================================================= class ARIClient: """Thin wrapper around the ARI REST API.""" def __init__(self, base_url: str, username: str, password: str): self.base_url = base_url.rstrip("/") self.auth = (username, password) self.session = requests.Session() self.session.auth = self.auth def get(self, path: str, **kwargs) -> Any: resp = self.session.get(f"{self.base_url}/ari{path}", **kwargs) resp.raise_for_status() return resp.json() if resp.content else None def post(self, path: str, **kwargs) -> Any: resp = self.session.post(f"{self.base_url}/ari{path}", **kwargs) resp.raise_for_status() return resp.json() if resp.content else None def delete(self, path: str, **kwargs) -> Any: resp = self.session.delete(f"{self.base_url}/ari{path}", **kwargs) resp.raise_for_status() return resp.json() if resp.content else None # ---- Channel Operations ---- def answer(self, channel_id: str): """Answer a channel.""" return self.post(f"/channels/{channel_id}/answer") def hangup(self, channel_id: str, reason: str = "normal"): """Hang up a channel.""" return self.delete(f"/channels/{channel_id}", params={"reason": reason}) def play(self, channel_id: str, media: str, playback_id: str = None): """Play media on a channel. Media format: 'sound:filename' or 'tone:dial'.""" params = {"media": media} if playback_id: params["playbackId"] = playback_id return self.post(f"/channels/{channel_id}/play", params=params) def dial(self, channel_id: str, endpoint: str, context: str = None, caller_id: str = None, timeout: int = 30): """Originate a call and bridge it.""" params = { "endpoint": endpoint, "app": ARI_APP, "timeout": timeout, } if caller_id: params["callerId"] = caller_id return self.post("/channels", params=params) # ---- Bridge Operations ---- def create_bridge(self, bridge_type: str = "mixing", name: str = None): """Create a bridge (conference).""" params = {"type": bridge_type} if name: params["name"] = name return self.post("/bridges", params=params) def add_to_bridge(self, bridge_id: str, channel_id: str): """Add a channel to a bridge.""" return self.post(f"/bridges/{bridge_id}/addChannel", params={"channel": channel_id}) def remove_from_bridge(self, bridge_id: str, channel_id: str): """Remove a channel from a bridge.""" return self.post(f"/bridges/{bridge_id}/removeChannel", params={"channel": channel_id}) def destroy_bridge(self, bridge_id: str): """Destroy a bridge.""" return self.delete(f"/bridges/{bridge_id}") # =============================================================================
# Call Handler
# ============================================================================= class CallHandler: """Handles a single call through the IVR flow.""" # IVR menu options: DTMF digit -> (description, PJSIP endpoint to dial) MENU = { "1": ("Sales", "PJSIP/1001"), "2": ("Support", "PJSIP/1004"), "3": ("Directory", None), # Sub-menu "0": ("Operator", "PJSIP/1001"), } def __init__(self, ari: ARIClient, channel_id: str, caller_info: dict): self.ari = ari self.channel_id = channel_id self.caller_info = caller_info self.state = "greeting" self.dtmf_buffer = "" self.attempts = 0 self.max_attempts = 3 self.bridge_id = None # Track state in Redis redis_client.hset(f"call:{channel_id}", mapping={ "state": self.state, "caller_num": caller_info.get("number", "unknown"), "caller_name": caller_info.get("name", "unknown"), "start_time": str(int(time.time())), }) def start(self): """Begin the IVR flow.""" log.info(f"Call {self.channel_id}: answering from {self.caller_info}") self.ari.answer(self.channel_id) time.sleep(0.5) # Brief pause after answer self._play_greeting() def _play_greeting(self): """Play the main greeting.""" self.state = "greeting" self._update_state() # Play greeting - uses built-in Asterisk sounds # In production, replace with custom recordings self.ari.play(self.channel_id, "sound:custom/welcome", playback_id=f"greeting-{self.channel_id}") def handle_dtmf(self, digit: str): """Process a DTMF digit.""" log.info(f"Call {self.channel_id}: DTMF '{digit}' in state '{self.state}'") if self.state in ("greeting", "waiting"): self._process_menu_selection(digit) elif self.state == "directory": self._process_directory_input(digit) def _process_menu_selection(self, digit: str): """Handle main menu DTMF selection.""" if digit in self.MENU: description, endpoint = self.MENU[digit] log.info(f"Call {self.channel_id}: selected '{description}'") if endpoint: self._transfer_to(endpoint, description) elif digit == "3": self.state = "directory" self._update_state() self.ari.play(self.channel_id, "sound:dir-pls-enter-person", playback_id=f"directory-{self.channel_id}") elif digit == "#": # Repeat menu self._play_greeting() else: self.ari.play(self.channel_id, "sound:option-is-invalid", playback_id=f"invalid-{self.channel_id}") self.attempts += 1 if self.attempts >= self.max_attempts: log.info(f"Call {self.channel_id}: max attempts, routing to operator") self._transfer_to("PJSIP/1001", "Operator (timeout)") def _transfer_to(self, endpoint: str, description: str): """Transfer the caller to an endpoint via a bridge.""" self.state = "transferring" self._update_state() try: # Create a bridge bridge = self.ari.create_bridge("mixing", f"transfer-{self.channel_id}") self.bridge_id = bridge["id"] # Add the caller to the bridge self.ari.add_to_bridge(self.bridge_id, self.channel_id) # Play ringing tone to caller self.ari.play(self.channel_id, "tone:ring", playback_id=f"ring-{self.channel_id}") # Originate a call to the target target = self.ari.dial(self.channel_id, endpoint, caller_id=self.caller_info.get("number", "")) log.info(f"Call {self.channel_id}: dialing {endpoint} " f"(bridge: {self.bridge_id})") # Store transfer info in Redis redis_client.hset(f"call:{self.channel_id}", mapping={ "state": "transferring", "transfer_to": description, "bridge_id": self.bridge_id, }) except Exception as e: log.error(f"Call {self.channel_id}: transfer failed: {e}") self.ari.play(self.channel_id, "sound:an-error-has-occurred", playback_id=f"error-{self.channel_id}") def _process_directory_input(self, digit: str): """Handle directory (spell-by-name) input.""" if digit == "*": # Go back to main menu self.state = "greeting" self._play_greeting() return self.dtmf_buffer += digit if len(self.dtmf_buffer) >= 3: # Look up by extension (simplified - real implementation would # search by name using the keypad mapping) ext = self.dtmf_buffer[:4] self.dtmf_buffer = "" log.info(f"Call {self.channel_id}: directory lookup for {ext}") self._transfer_to(f"PJSIP/{ext}", f"Directory: {ext}") def handle_playback_finished(self, playback_id: str): """Handle playback completion.""" if playback_id.startswith("greeting-"): self.state = "waiting" self._update_state() # Wait for DTMF, replay after timeout handled by dialplan def cleanup(self): """Clean up when the call ends.""" log.info(f"Call {self.channel_id}: cleanup") if self.bridge_id: try: self.ari.destroy_bridge(self.bridge_id) except Exception: pass redis_client.delete(f"call:{self.channel_id}") def _update_state(self): """Update call state in Redis.""" redis_client.hset(f"call:{self.channel_id}", "state", self.state) # =============================================================================
# WebSocket Event Handler
# ============================================================================= # Active call handlers
calls: dict[str, CallHandler] = {}
ari = ARIClient(ARI_URL, ARI_USER, ARI_PASS) def on_message(ws, message): """Handle incoming WebSocket events from Asterisk.""" try: event = json.loads(message) event_type = event.get("type", "unknown") channel = event.get("channel", {}) channel_id = channel.get("id", "") if event_type == "StasisStart": # New call entered our application caller = channel.get("caller", {}) caller_info = { "number": caller.get("number", "unknown"), "name": caller.get("name", "unknown"), } log.info(f"StasisStart: {channel_id} from {caller_info}") handler = CallHandler(ari, channel_id, caller_info) calls[channel_id] = handler # Start IVR in a separate thread to avoid blocking the event loop threading.Thread(target=handler.start, daemon=True).start() elif event_type == "StasisEnd": # Call left our application log.info(f"StasisEnd: {channel_id}") handler = calls.pop(channel_id, None) if handler: handler.cleanup() elif event_type == "ChannelDtmfReceived": # DTMF digit received digit = event.get("digit", "") handler = calls.get(channel_id) if handler: handler.handle_dtmf(digit) elif event_type == "PlaybackFinished": # Audio playback completed playback = event.get("playback", {}) playback_id = playback.get("id", "") # Find which call this playback belongs to target_channel = playback.get("target_uri", "").replace("channel:", "") handler = calls.get(target_channel) if handler: handler.handle_playback_finished(playback_id) elif event_type == "ChannelDestroyed": # Channel was destroyed (hangup) handler = calls.pop(channel_id, None) if handler: handler.cleanup() except Exception as e: log.error(f"Error handling event: {e}", exc_info=True) def on_error(ws, error): log.error(f"WebSocket error: {error}") def on_close(ws, close_status_code, close_msg): log.warning(f"WebSocket closed: {close_status_code} - {close_msg}") def on_open(ws): log.info("WebSocket connected to Asterisk ARI") # =============================================================================
# Main
# ============================================================================= def main(): """Connect to ARI WebSocket and handle events.""" log.info(f"Starting ARI Auto-Attendant") log.info(f"ARI URL: {ARI_URL}") log.info(f"Application: {ARI_APP}") # Verify ARI is reachable try: info = ari.get("/asterisk/info") log.info(f"Connected to Asterisk {info.get('system', {}).get('version', 'unknown')}") except Exception as e: log.error(f"Cannot connect to ARI: {e}") sys.exit(1) # Build WebSocket URL ws_url = ARI_URL.replace("http://", "ws://").replace("https://", "wss://") ws_url = f"{ws_url}/ari/events?api_key={ARI_USER}:{ARI_PASS}&app={ARI_APP}" # Connect with auto-reconnect while True: try: ws = websocket.WebSocketApp( ws_url, on_open=on_open, on_message=on_message, on_error=on_error, on_close=on_close, ) ws.run_forever(ping_interval=30, ping_timeout=10) except KeyboardInterrupt: log.info("Shutting down...") break except Exception as e: log.error(f"WebSocket connection failed: {e}") log.info("Reconnecting in 5 seconds...") time.sleep(5) if __name__ == "__main__": main()
#!/usr/bin/env python3
"""
Asterisk ARI Auto-Attendant Application
Handles inbound calls via REST API + WebSocket events. Requires: - Asterisk with ARI enabled (http.conf + ari.conf) - Dialplan: exten => _X.,1,Stasis(autoattendant,${EXTEN}) - pip install requests websocket-client redis
""" import json
import logging
import os
import sys
import threading
import time
from typing import Any import redis
import requests
import websocket # =============================================================================
# Configuration
# ============================================================================= ARI_URL = os.getenv("ARI_URL", "http://asterisk:8088")
ARI_USER = os.getenv("ARI_USERNAME", "ari_user")
ARI_PASS = os.getenv("ARI_PASSWORD", "Ar1_S3cur3_2026!")
ARI_APP = os.getenv("ARI_APP", "autoattendant") REDIS_HOST = os.getenv("REDIS_HOST", "redis")
REDIS_PORT = int(os.getenv("REDIS_PORT", "6379"))
REDIS_PASS = os.getenv("REDIS_PASSWORD", "R3d1s_S3cur3_2026!") # Logging
logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s", datefmt="%Y-%m-%d %H:%M:%S",
)
log = logging.getLogger("ari-autoattendant") # =============================================================================
# Redis Client (for state tracking)
# ============================================================================= redis_client = redis.Redis( host=REDIS_HOST, port=REDIS_PORT, password=REDIS_PASS, decode_responses=True,
) # =============================================================================
# ARI REST Client
# ============================================================================= class ARIClient: """Thin wrapper around the ARI REST API.""" def __init__(self, base_url: str, username: str, password: str): self.base_url = base_url.rstrip("/") self.auth = (username, password) self.session = requests.Session() self.session.auth = self.auth def get(self, path: str, **kwargs) -> Any: resp = self.session.get(f"{self.base_url}/ari{path}", **kwargs) resp.raise_for_status() return resp.json() if resp.content else None def post(self, path: str, **kwargs) -> Any: resp = self.session.post(f"{self.base_url}/ari{path}", **kwargs) resp.raise_for_status() return resp.json() if resp.content else None def delete(self, path: str, **kwargs) -> Any: resp = self.session.delete(f"{self.base_url}/ari{path}", **kwargs) resp.raise_for_status() return resp.json() if resp.content else None # ---- Channel Operations ---- def answer(self, channel_id: str): """Answer a channel.""" return self.post(f"/channels/{channel_id}/answer") def hangup(self, channel_id: str, reason: str = "normal"): """Hang up a channel.""" return self.delete(f"/channels/{channel_id}", params={"reason": reason}) def play(self, channel_id: str, media: str, playback_id: str = None): """Play media on a channel. Media format: 'sound:filename' or 'tone:dial'.""" params = {"media": media} if playback_id: params["playbackId"] = playback_id return self.post(f"/channels/{channel_id}/play", params=params) def dial(self, channel_id: str, endpoint: str, context: str = None, caller_id: str = None, timeout: int = 30): """Originate a call and bridge it.""" params = { "endpoint": endpoint, "app": ARI_APP, "timeout": timeout, } if caller_id: params["callerId"] = caller_id return self.post("/channels", params=params) # ---- Bridge Operations ---- def create_bridge(self, bridge_type: str = "mixing", name: str = None): """Create a bridge (conference).""" params = {"type": bridge_type} if name: params["name"] = name return self.post("/bridges", params=params) def add_to_bridge(self, bridge_id: str, channel_id: str): """Add a channel to a bridge.""" return self.post(f"/bridges/{bridge_id}/addChannel", params={"channel": channel_id}) def remove_from_bridge(self, bridge_id: str, channel_id: str): """Remove a channel from a bridge.""" return self.post(f"/bridges/{bridge_id}/removeChannel", params={"channel": channel_id}) def destroy_bridge(self, bridge_id: str): """Destroy a bridge.""" return self.delete(f"/bridges/{bridge_id}") # =============================================================================
# Call Handler
# ============================================================================= class CallHandler: """Handles a single call through the IVR flow.""" # IVR menu options: DTMF digit -> (description, PJSIP endpoint to dial) MENU = { "1": ("Sales", "PJSIP/1001"), "2": ("Support", "PJSIP/1004"), "3": ("Directory", None), # Sub-menu "0": ("Operator", "PJSIP/1001"), } def __init__(self, ari: ARIClient, channel_id: str, caller_info: dict): self.ari = ari self.channel_id = channel_id self.caller_info = caller_info self.state = "greeting" self.dtmf_buffer = "" self.attempts = 0 self.max_attempts = 3 self.bridge_id = None # Track state in Redis redis_client.hset(f"call:{channel_id}", mapping={ "state": self.state, "caller_num": caller_info.get("number", "unknown"), "caller_name": caller_info.get("name", "unknown"), "start_time": str(int(time.time())), }) def start(self): """Begin the IVR flow.""" log.info(f"Call {self.channel_id}: answering from {self.caller_info}") self.ari.answer(self.channel_id) time.sleep(0.5) # Brief pause after answer self._play_greeting() def _play_greeting(self): """Play the main greeting.""" self.state = "greeting" self._update_state() # Play greeting - uses built-in Asterisk sounds # In production, replace with custom recordings self.ari.play(self.channel_id, "sound:custom/welcome", playback_id=f"greeting-{self.channel_id}") def handle_dtmf(self, digit: str): """Process a DTMF digit.""" log.info(f"Call {self.channel_id}: DTMF '{digit}' in state '{self.state}'") if self.state in ("greeting", "waiting"): self._process_menu_selection(digit) elif self.state == "directory": self._process_directory_input(digit) def _process_menu_selection(self, digit: str): """Handle main menu DTMF selection.""" if digit in self.MENU: description, endpoint = self.MENU[digit] log.info(f"Call {self.channel_id}: selected '{description}'") if endpoint: self._transfer_to(endpoint, description) elif digit == "3": self.state = "directory" self._update_state() self.ari.play(self.channel_id, "sound:dir-pls-enter-person", playback_id=f"directory-{self.channel_id}") elif digit == "#": # Repeat menu self._play_greeting() else: self.ari.play(self.channel_id, "sound:option-is-invalid", playback_id=f"invalid-{self.channel_id}") self.attempts += 1 if self.attempts >= self.max_attempts: log.info(f"Call {self.channel_id}: max attempts, routing to operator") self._transfer_to("PJSIP/1001", "Operator (timeout)") def _transfer_to(self, endpoint: str, description: str): """Transfer the caller to an endpoint via a bridge.""" self.state = "transferring" self._update_state() try: # Create a bridge bridge = self.ari.create_bridge("mixing", f"transfer-{self.channel_id}") self.bridge_id = bridge["id"] # Add the caller to the bridge self.ari.add_to_bridge(self.bridge_id, self.channel_id) # Play ringing tone to caller self.ari.play(self.channel_id, "tone:ring", playback_id=f"ring-{self.channel_id}") # Originate a call to the target target = self.ari.dial(self.channel_id, endpoint, caller_id=self.caller_info.get("number", "")) log.info(f"Call {self.channel_id}: dialing {endpoint} " f"(bridge: {self.bridge_id})") # Store transfer info in Redis redis_client.hset(f"call:{self.channel_id}", mapping={ "state": "transferring", "transfer_to": description, "bridge_id": self.bridge_id, }) except Exception as e: log.error(f"Call {self.channel_id}: transfer failed: {e}") self.ari.play(self.channel_id, "sound:an-error-has-occurred", playback_id=f"error-{self.channel_id}") def _process_directory_input(self, digit: str): """Handle directory (spell-by-name) input.""" if digit == "*": # Go back to main menu self.state = "greeting" self._play_greeting() return self.dtmf_buffer += digit if len(self.dtmf_buffer) >= 3: # Look up by extension (simplified - real implementation would # search by name using the keypad mapping) ext = self.dtmf_buffer[:4] self.dtmf_buffer = "" log.info(f"Call {self.channel_id}: directory lookup for {ext}") self._transfer_to(f"PJSIP/{ext}", f"Directory: {ext}") def handle_playback_finished(self, playback_id: str): """Handle playback completion.""" if playback_id.startswith("greeting-"): self.state = "waiting" self._update_state() # Wait for DTMF, replay after timeout handled by dialplan def cleanup(self): """Clean up when the call ends.""" log.info(f"Call {self.channel_id}: cleanup") if self.bridge_id: try: self.ari.destroy_bridge(self.bridge_id) except Exception: pass redis_client.delete(f"call:{self.channel_id}") def _update_state(self): """Update call state in Redis.""" redis_client.hset(f"call:{self.channel_id}", "state", self.state) # =============================================================================
# WebSocket Event Handler
# ============================================================================= # Active call handlers
calls: dict[str, CallHandler] = {}
ari = ARIClient(ARI_URL, ARI_USER, ARI_PASS) def on_message(ws, message): """Handle incoming WebSocket events from Asterisk.""" try: event = json.loads(message) event_type = event.get("type", "unknown") channel = event.get("channel", {}) channel_id = channel.get("id", "") if event_type == "StasisStart": # New call entered our application caller = channel.get("caller", {}) caller_info = { "number": caller.get("number", "unknown"), "name": caller.get("name", "unknown"), } log.info(f"StasisStart: {channel_id} from {caller_info}") handler = CallHandler(ari, channel_id, caller_info) calls[channel_id] = handler # Start IVR in a separate thread to avoid blocking the event loop threading.Thread(target=handler.start, daemon=True).start() elif event_type == "StasisEnd": # Call left our application log.info(f"StasisEnd: {channel_id}") handler = calls.pop(channel_id, None) if handler: handler.cleanup() elif event_type == "ChannelDtmfReceived": # DTMF digit received digit = event.get("digit", "") handler = calls.get(channel_id) if handler: handler.handle_dtmf(digit) elif event_type == "PlaybackFinished": # Audio playback completed playback = event.get("playback", {}) playback_id = playback.get("id", "") # Find which call this playback belongs to target_channel = playback.get("target_uri", "").replace("channel:", "") handler = calls.get(target_channel) if handler: handler.handle_playback_finished(playback_id) elif event_type == "ChannelDestroyed": # Channel was destroyed (hangup) handler = calls.pop(channel_id, None) if handler: handler.cleanup() except Exception as e: log.error(f"Error handling event: {e}", exc_info=True) def on_error(ws, error): log.error(f"WebSocket error: {error}") def on_close(ws, close_status_code, close_msg): log.warning(f"WebSocket closed: {close_status_code} - {close_msg}") def on_open(ws): log.info("WebSocket connected to Asterisk ARI") # =============================================================================
# Main
# ============================================================================= def main(): """Connect to ARI WebSocket and handle events.""" log.info(f"Starting ARI Auto-Attendant") log.info(f"ARI URL: {ARI_URL}") log.info(f"Application: {ARI_APP}") # Verify ARI is reachable try: info = ari.get("/asterisk/info") log.info(f"Connected to Asterisk {info.get('system', {}).get('version', 'unknown')}") except Exception as e: log.error(f"Cannot connect to ARI: {e}") sys.exit(1) # Build WebSocket URL ws_url = ARI_URL.replace("http://", "ws://").replace("https://", "wss://") ws_url = f"{ws_url}/ari/events?api_key={ARI_USER}:{ARI_PASS}&app={ARI_APP}" # Connect with auto-reconnect while True: try: ws = websocket.WebSocketApp( ws_url, on_open=on_open, on_message=on_message, on_error=on_error, on_close=on_close, ) ws.run_forever(ping_interval=30, ping_timeout=10) except KeyboardInterrupt: log.info("Shutting down...") break except Exception as e: log.error(f"WebSocket connection failed: {e}") log.info("Reconnecting in 5 seconds...") time.sleep(5) if __name__ == "__main__": main()
# --------------------------------------------------------------------------- # ARI Application - Auto-Attendant # --------------------------------------------------------------------------- ari-app: build: context: ./ari-app dockerfile: Dockerfile container_name: asterisk-ari-app restart: unless-stopped networks: - asterisk-net environment: - ARI_URL=http://asterisk:8088 - ARI_USERNAME=${ARI_USERNAME} - ARI_PASSWORD=${ARI_PASSWORD} - ARI_APP=autoattendant - REDIS_HOST=${REDIS_HOST:-redis} - REDIS_PORT=${REDIS_PORT:-6379} - REDIS_PASSWORD=${REDIS_PASSWORD} depends_on: asterisk: condition: service_healthy redis: condition: service_healthy deploy: resources: limits: cpus: '0.5' memory: 256M
# --------------------------------------------------------------------------- # ARI Application - Auto-Attendant # --------------------------------------------------------------------------- ari-app: build: context: ./ari-app dockerfile: Dockerfile container_name: asterisk-ari-app restart: unless-stopped networks: - asterisk-net environment: - ARI_URL=http://asterisk:8088 - ARI_USERNAME=${ARI_USERNAME} - ARI_PASSWORD=${ARI_PASSWORD} - ARI_APP=autoattendant - REDIS_HOST=${REDIS_HOST:-redis} - REDIS_PORT=${REDIS_PORT:-6379} - REDIS_PASSWORD=${REDIS_PASSWORD} depends_on: asterisk: condition: service_healthy redis: condition: service_healthy deploy: resources: limits: cpus: '0.5' memory: 256M
# --------------------------------------------------------------------------- # ARI Application - Auto-Attendant # --------------------------------------------------------------------------- ari-app: build: context: ./ari-app dockerfile: Dockerfile container_name: asterisk-ari-app restart: unless-stopped networks: - asterisk-net environment: - ARI_URL=http://asterisk:8088 - ARI_USERNAME=${ARI_USERNAME} - ARI_PASSWORD=${ARI_PASSWORD} - ARI_APP=autoattendant - REDIS_HOST=${REDIS_HOST:-redis} - REDIS_PORT=${REDIS_PORT:-6379} - REDIS_PASSWORD=${REDIS_PASSWORD} depends_on: asterisk: condition: service_healthy redis: condition: service_healthy deploy: resources: limits: cpus: '0.5' memory: 256M
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
CMD ["python", "-u", "app.py"]
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
CMD ["python", "-u", "app.py"]
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
CMD ["python", "-u", "app.py"]
# Verify ARI is running
curl -s -u ari_user:Ar1_S3cur3_2026! http://localhost:8088/ari/asterisk/info | jq . # List registered applications
curl -s -u ari_user:Ar1_S3cur3_2026! http://localhost:8088/ari/applications | jq . # List active channels
curl -s -u ari_user:Ar1_S3cur3_2026! http://localhost:8088/ari/channels | jq . # Originate a test call (rings extension 1001)
curl -s -X POST -u ari_user:Ar1_S3cur3_2026! \ "http://localhost:8088/ari/channels?endpoint=PJSIP/1001&app=autoattendant&callerId=Test+Call+<9999>" # Watch ARI events in real-time
wscat -c "ws://localhost:8088/ari/events?api_key=ari_user:Ar1_S3cur3_2026!&app=autoattendant"
# Verify ARI is running
curl -s -u ari_user:Ar1_S3cur3_2026! http://localhost:8088/ari/asterisk/info | jq . # List registered applications
curl -s -u ari_user:Ar1_S3cur3_2026! http://localhost:8088/ari/applications | jq . # List active channels
curl -s -u ari_user:Ar1_S3cur3_2026! http://localhost:8088/ari/channels | jq . # Originate a test call (rings extension 1001)
curl -s -X POST -u ari_user:Ar1_S3cur3_2026! \ "http://localhost:8088/ari/channels?endpoint=PJSIP/1001&app=autoattendant&callerId=Test+Call+<9999>" # Watch ARI events in real-time
wscat -c "ws://localhost:8088/ari/events?api_key=ari_user:Ar1_S3cur3_2026!&app=autoattendant"
# Verify ARI is running
curl -s -u ari_user:Ar1_S3cur3_2026! http://localhost:8088/ari/asterisk/info | jq . # List registered applications
curl -s -u ari_user:Ar1_S3cur3_2026! http://localhost:8088/ari/applications | jq . # List active channels
curl -s -u ari_user:Ar1_S3cur3_2026! http://localhost:8088/ari/channels | jq . # Originate a test call (rings extension 1001)
curl -s -X POST -u ari_user:Ar1_S3cur3_2026! \ "http://localhost:8088/ari/channels?endpoint=PJSIP/1001&app=autoattendant&callerId=Test+Call+<9999>" # Watch ARI events in real-time
wscat -c "ws://localhost:8088/ari/events?api_key=ari_user:Ar1_S3cur3_2026!&app=autoattendant"
; Transport - must use WSS (not WS) for production
[transport-wss]
type = transport
protocol = wss
bind = 0.0.0.0:8089 ; Endpoint template for WebRTC
[endpoint-webrtc](!)
type = endpoint
webrtc = yes ; Shortcut that enables: ; use_avpf = yes ; media_encryption = dtls ; dtls_verify = fingerprint ; dtls_setup = actpass ; ice_support = yes ; media_use_received_transport = yes ; rtcp_mux = yes
dtls_auto_generate_cert = yes ; Auto-generate DTLS cert (simplest)
; Transport - must use WSS (not WS) for production
[transport-wss]
type = transport
protocol = wss
bind = 0.0.0.0:8089 ; Endpoint template for WebRTC
[endpoint-webrtc](!)
type = endpoint
webrtc = yes ; Shortcut that enables: ; use_avpf = yes ; media_encryption = dtls ; dtls_verify = fingerprint ; dtls_setup = actpass ; ice_support = yes ; media_use_received_transport = yes ; rtcp_mux = yes
dtls_auto_generate_cert = yes ; Auto-generate DTLS cert (simplest)
; Transport - must use WSS (not WS) for production
[transport-wss]
type = transport
protocol = wss
bind = 0.0.0.0:8089 ; Endpoint template for WebRTC
[endpoint-webrtc](!)
type = endpoint
webrtc = yes ; Shortcut that enables: ; use_avpf = yes ; media_encryption = dtls ; dtls_verify = fingerprint ; dtls_setup = actpass ; ice_support = yes ; media_use_received_transport = yes ; rtcp_mux = yes
dtls_auto_generate_cert = yes ; Auto-generate DTLS cert (simplest)
# =============================================================================
# Coturn TURN/STUN Server Configuration
# Required for WebRTC clients behind NAT
# ============================================================================= # Network settings
listening-port=3478
# TLS listening port
tls-listening-port=5349 # Relay port range (for media relay)
min-port=49152
max-port=65535 # External IP (your server's public IP)
external-ip=YOUR_SERVER_IP # Realm
realm=YOUR_DOMAIN # Authentication
# Use long-term credentials with a shared secret
# The secret must match what your web client uses to generate credentials
use-auth-secret
static-auth-secret=T0rn_Sh4r3d_S3cr3t_2026! # TLS certificates (shared with Let's Encrypt)
cert=/etc/certs/live/YOUR_DOMAIN/fullchain.pem
pkey=/etc/certs/live/YOUR_DOMAIN/privkey.pem # Logging
log-file=stdout
verbose # Performance
total-quota=100
stale-nonce=600
max-bps=1000000 # Security
no-multicast-peers
no-cli
denied-peer-ip=10.0.0.0-10.255.255.255
denied-peer-ip=172.16.0.0-172.31.255.255
denied-peer-ip=192.168.0.0-192.168.255.255
denied-peer-ip=127.0.0.0-127.255.255.255 # Only allow UDP and TCP relay
no-udp-relay=false
no-tcp-relay=false # Fingerprint for STUN messages (recommended)
fingerprint # Enable channel binding lifetime management
channel-lifetime=600
# =============================================================================
# Coturn TURN/STUN Server Configuration
# Required for WebRTC clients behind NAT
# ============================================================================= # Network settings
listening-port=3478
# TLS listening port
tls-listening-port=5349 # Relay port range (for media relay)
min-port=49152
max-port=65535 # External IP (your server's public IP)
external-ip=YOUR_SERVER_IP # Realm
realm=YOUR_DOMAIN # Authentication
# Use long-term credentials with a shared secret
# The secret must match what your web client uses to generate credentials
use-auth-secret
static-auth-secret=T0rn_Sh4r3d_S3cr3t_2026! # TLS certificates (shared with Let's Encrypt)
cert=/etc/certs/live/YOUR_DOMAIN/fullchain.pem
pkey=/etc/certs/live/YOUR_DOMAIN/privkey.pem # Logging
log-file=stdout
verbose # Performance
total-quota=100
stale-nonce=600
max-bps=1000000 # Security
no-multicast-peers
no-cli
denied-peer-ip=10.0.0.0-10.255.255.255
denied-peer-ip=172.16.0.0-172.31.255.255
denied-peer-ip=192.168.0.0-192.168.255.255
denied-peer-ip=127.0.0.0-127.255.255.255 # Only allow UDP and TCP relay
no-udp-relay=false
no-tcp-relay=false # Fingerprint for STUN messages (recommended)
fingerprint # Enable channel binding lifetime management
channel-lifetime=600
# =============================================================================
# Coturn TURN/STUN Server Configuration
# Required for WebRTC clients behind NAT
# ============================================================================= # Network settings
listening-port=3478
# TLS listening port
tls-listening-port=5349 # Relay port range (for media relay)
min-port=49152
max-port=65535 # External IP (your server's public IP)
external-ip=YOUR_SERVER_IP # Realm
realm=YOUR_DOMAIN # Authentication
# Use long-term credentials with a shared secret
# The secret must match what your web client uses to generate credentials
use-auth-secret
static-auth-secret=T0rn_Sh4r3d_S3cr3t_2026! # TLS certificates (shared with Let's Encrypt)
cert=/etc/certs/live/YOUR_DOMAIN/fullchain.pem
pkey=/etc/certs/live/YOUR_DOMAIN/privkey.pem # Logging
log-file=stdout
verbose # Performance
total-quota=100
stale-nonce=600
max-bps=1000000 # Security
no-multicast-peers
no-cli
denied-peer-ip=10.0.0.0-10.255.255.255
denied-peer-ip=172.16.0.0-172.31.255.255
denied-peer-ip=192.168.0.0-192.168.255.255
denied-peer-ip=127.0.0.0-127.255.255.255 # Only allow UDP and TCP relay
no-udp-relay=false
no-tcp-relay=false # Fingerprint for STUN messages (recommended)
fingerprint # Enable channel binding lifetime management
channel-lifetime=600
# =============================================================================
# Nginx Configuration - Asterisk Reverse Proxy
# ============================================================================= user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid; events { worker_connections 1024;
} http { include /etc/nginx/mime.types; default_type application/octet-stream; log_format main '$remote_addr - $remote_user [$time_local] "$request" ' '$status $body_bytes_sent "$http_referer" ' '"$http_user_agent"'; access_log /var/log/nginx/access.log main; sendfile on; tcp_nopush on; keepalive_timeout 65; client_max_body_size 50M; # WebSocket upgrade map map $http_upgrade $connection_upgrade { default upgrade; '' close; } # HTTP -> HTTPS redirect server { listen 80; server_name YOUR_DOMAIN; # ACME challenge for Let's Encrypt location /.well-known/acme-challenge/ { root /var/www/certbot; } # Health check endpoint location /health { access_log off; return 200 "OK\n"; add_header Content-Type text/plain; } # Redirect everything else to HTTPS location / { return 301 https://$host$request_uri; } } # HTTPS server server { listen 443 ssl http2; server_name YOUR_DOMAIN; # TLS certificates (from Let's Encrypt via certbot) ssl_certificate /etc/nginx/certs/live/YOUR_DOMAIN/fullchain.pem; ssl_certificate_key /etc/nginx/certs/live/YOUR_DOMAIN/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384; ssl_prefer_server_ciphers off; ssl_session_timeout 1d; ssl_session_cache shared:SSL:10m; # HSTS add_header Strict-Transport-Security "max-age=63072000" always; # ARI REST API proxy location /ari/ { proxy_pass http://asterisk:8088/ari/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } # ARI WebSocket proxy location /ari/events { proxy_pass http://asterisk:8088/ari/events; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_read_timeout 86400; proxy_send_timeout 86400; } # SIP WebSocket proxy (for WebRTC SIP.js clients) location /ws { proxy_pass http://asterisk:8088/ws; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_read_timeout 86400; proxy_send_timeout 86400; } # Static web phone files (optional) location / { root /var/www/webphone; index index.html; try_files $uri $uri/ =404; } # Health check location /health { access_log off; return 200 "OK\n"; add_header Content-Type text/plain; } }
}
# =============================================================================
# Nginx Configuration - Asterisk Reverse Proxy
# ============================================================================= user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid; events { worker_connections 1024;
} http { include /etc/nginx/mime.types; default_type application/octet-stream; log_format main '$remote_addr - $remote_user [$time_local] "$request" ' '$status $body_bytes_sent "$http_referer" ' '"$http_user_agent"'; access_log /var/log/nginx/access.log main; sendfile on; tcp_nopush on; keepalive_timeout 65; client_max_body_size 50M; # WebSocket upgrade map map $http_upgrade $connection_upgrade { default upgrade; '' close; } # HTTP -> HTTPS redirect server { listen 80; server_name YOUR_DOMAIN; # ACME challenge for Let's Encrypt location /.well-known/acme-challenge/ { root /var/www/certbot; } # Health check endpoint location /health { access_log off; return 200 "OK\n"; add_header Content-Type text/plain; } # Redirect everything else to HTTPS location / { return 301 https://$host$request_uri; } } # HTTPS server server { listen 443 ssl http2; server_name YOUR_DOMAIN; # TLS certificates (from Let's Encrypt via certbot) ssl_certificate /etc/nginx/certs/live/YOUR_DOMAIN/fullchain.pem; ssl_certificate_key /etc/nginx/certs/live/YOUR_DOMAIN/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384; ssl_prefer_server_ciphers off; ssl_session_timeout 1d; ssl_session_cache shared:SSL:10m; # HSTS add_header Strict-Transport-Security "max-age=63072000" always; # ARI REST API proxy location /ari/ { proxy_pass http://asterisk:8088/ari/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } # ARI WebSocket proxy location /ari/events { proxy_pass http://asterisk:8088/ari/events; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_read_timeout 86400; proxy_send_timeout 86400; } # SIP WebSocket proxy (for WebRTC SIP.js clients) location /ws { proxy_pass http://asterisk:8088/ws; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_read_timeout 86400; proxy_send_timeout 86400; } # Static web phone files (optional) location / { root /var/www/webphone; index index.html; try_files $uri $uri/ =404; } # Health check location /health { access_log off; return 200 "OK\n"; add_header Content-Type text/plain; } }
}
# =============================================================================
# Nginx Configuration - Asterisk Reverse Proxy
# ============================================================================= user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid; events { worker_connections 1024;
} http { include /etc/nginx/mime.types; default_type application/octet-stream; log_format main '$remote_addr - $remote_user [$time_local] "$request" ' '$status $body_bytes_sent "$http_referer" ' '"$http_user_agent"'; access_log /var/log/nginx/access.log main; sendfile on; tcp_nopush on; keepalive_timeout 65; client_max_body_size 50M; # WebSocket upgrade map map $http_upgrade $connection_upgrade { default upgrade; '' close; } # HTTP -> HTTPS redirect server { listen 80; server_name YOUR_DOMAIN; # ACME challenge for Let's Encrypt location /.well-known/acme-challenge/ { root /var/www/certbot; } # Health check endpoint location /health { access_log off; return 200 "OK\n"; add_header Content-Type text/plain; } # Redirect everything else to HTTPS location / { return 301 https://$host$request_uri; } } # HTTPS server server { listen 443 ssl http2; server_name YOUR_DOMAIN; # TLS certificates (from Let's Encrypt via certbot) ssl_certificate /etc/nginx/certs/live/YOUR_DOMAIN/fullchain.pem; ssl_certificate_key /etc/nginx/certs/live/YOUR_DOMAIN/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384; ssl_prefer_server_ciphers off; ssl_session_timeout 1d; ssl_session_cache shared:SSL:10m; # HSTS add_header Strict-Transport-Security "max-age=63072000" always; # ARI REST API proxy location /ari/ { proxy_pass http://asterisk:8088/ari/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } # ARI WebSocket proxy location /ari/events { proxy_pass http://asterisk:8088/ari/events; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_read_timeout 86400; proxy_send_timeout 86400; } # SIP WebSocket proxy (for WebRTC SIP.js clients) location /ws { proxy_pass http://asterisk:8088/ws; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_read_timeout 86400; proxy_send_timeout 86400; } # Static web phone files (optional) location / { root /var/www/webphone; index index.html; try_files $uri $uri/ =404; } # Health check location /health { access_log off; return 200 "OK\n"; add_header Content-Type text/plain; } }
}
# Start just Nginx (for ACME challenge)
docker compose up -d nginx # Request certificate
docker compose run --rm certbot certonly \ --webroot \ --webroot-path /var/www/certbot \ -d YOUR_DOMAIN \ --email admin@YOUR_DOMAIN \ --agree-tos \ --no-eff-email # Restart Nginx with the new certificate
docker compose restart nginx
# Start just Nginx (for ACME challenge)
docker compose up -d nginx # Request certificate
docker compose run --rm certbot certonly \ --webroot \ --webroot-path /var/www/certbot \ -d YOUR_DOMAIN \ --email admin@YOUR_DOMAIN \ --agree-tos \ --no-eff-email # Restart Nginx with the new certificate
docker compose restart nginx
# Start just Nginx (for ACME challenge)
docker compose up -d nginx # Request certificate
docker compose run --rm certbot certonly \ --webroot \ --webroot-path /var/www/certbot \ -d YOUR_DOMAIN \ --email admin@YOUR_DOMAIN \ --agree-tos \ --no-eff-email # Restart Nginx with the new certificate
docker compose restart nginx
<!DOCTYPE html>
<html lang="en">
<head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Web Phone</title> <script src="https://unpkg.com/[email protected]/lib/platform/web/sip.js"></script> <style> * { box-sizing: border-box; margin: 0; padding: 0; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; display: flex; justify-content: center; align-items: center; min-height: 100vh; background: #1a1a2e; } .phone { background: #16213e; border-radius: 20px; padding: 30px; width: 320px; box-shadow: 0 20px 60px rgba(0,0,0,0.5); } .display { background: #0f3460; border-radius: 10px; padding: 15px; margin-bottom: 20px; text-align: center; } .display .number { color: #e94560; font-size: 24px; font-weight: bold; min-height: 36px; word-break: break-all; } .display .status { color: #a0a0a0; font-size: 12px; margin-top: 5px; } .keypad { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin-bottom: 20px; } .key { background: #0f3460; border: none; color: white; font-size: 22px; padding: 18px; border-radius: 10px; cursor: pointer; transition: background 0.2s; } .key:hover { background: #1a5276; } .key:active { background: #e94560; } .key .sub { display: block; font-size: 10px; color: #666; } .actions { display: flex; gap: 10px; } .btn { flex: 1; padding: 15px; border: none; border-radius: 10px; font-size: 16px; font-weight: bold; cursor: pointer; } .btn-call { background: #27ae60; color: white; } .btn-call:hover { background: #2ecc71; } .btn-hangup { background: #e74c3c; color: white; } .btn-hangup:hover { background: #c0392b; } .btn:disabled { opacity: 0.3; cursor: not-allowed; } .config { margin-top: 15px; padding-top: 15px; border-top: 1px solid #0f3460; } .config input { width: 100%; padding: 8px; margin: 3px 0; background: #0f3460; border: 1px solid #1a5276; color: white; border-radius: 5px; font-size: 12px; } .config label { color: #666; font-size: 11px; } .config button { width: 100%; padding: 10px; margin-top: 8px; background: #3498db; color: white; border: none; border-radius: 5px; cursor: pointer; font-size: 13px; } audio { display: none; } </style>
</head>
<body> <div class="phone"> <div class="display"> <div class="number" id="display">Ready</div> <div class="status" id="status">Not registered</div> </div> <div class="keypad"> <button class="key" onclick="press('1')">1<span class="sub"> </span></button> <button class="key" onclick="press('2')">2<span class="sub">ABC</span></button> <button class="key" onclick="press('3')">3<span class="sub">DEF</span></button> <button class="key" onclick="press('4')">4<span class="sub">GHI</span></button> <button class="key" onclick="press('5')">5<span class="sub">JKL</span></button> <button class="key" onclick="press('6')">6<span class="sub">MNO</span></button> <button class="key" onclick="press('7')">7<span class="sub">PQRS</span></button> <button class="key" onclick="press('8')">8<span class="sub">TUV</span></button> <button class="key" onclick="press('9')">9<span class="sub">WXYZ</span></button> <button class="key" onclick="press('*')">*</button> <button class="key" onclick="press('0')">0<span class="sub">+</span></button> <button class="key" onclick="press('#')">#</button> </div> <div class="actions"> <button class="btn btn-call" id="btnCall" onclick="makeCall()">Call</button> <button class="btn btn-hangup" id="btnHangup" onclick="hangUp()" disabled>Hang Up</button> </div> <div class="config" id="configPanel"> <label>Server (WSS URL)</label> <input id="cfgServer" value="wss://YOUR_DOMAIN/ws" placeholder="wss://pbx.example.com/ws"> <label>Extension</label> <input id="cfgExt" value="1010" placeholder="1010"> <label>Password</label> <input id="cfgPass" type="password" value="" placeholder="SIP password"> <label>TURN Server</label> <input id="cfgTurn" value="turn:YOUR_DOMAIN:3478" placeholder="turn:example.com:3478"> <label>TURN Password</label> <input id="cfgTurnPass" type="password" value="" placeholder="TURN shared secret"> <button onclick="register()">Register</button> </div> <audio id="remoteAudio" autoplay></audio> <audio id="localAudio" autoplay muted></audio> </div> <script> let userAgent = null; let currentSession = null; let dialedNumber = ''; const display = document.getElementById('display'); const status = document.getElementById('status'); const btnCall = document.getElementById('btnCall'); const btnHangup = document.getElementById('btnHangup'); function press(digit) { dialedNumber += digit; display.textContent = dialedNumber; // Send DTMF if in-call if (currentSession) { currentSession.sessionDescriptionHandler .sendDtmf(digit); } } function setStatus(text, isError = false) { status.textContent = text; status.style.color = isError ? '#e74c3c' : '#a0a0a0'; } function register() { const server = document.getElementById('cfgServer').value; const ext = document.getElementById('cfgExt').value; const pass = document.getElementById('cfgPass').value; const turnServer = document.getElementById('cfgTurn').value; const turnPass = document.getElementById('cfgTurnPass').value; // Extract domain from WSS URL const domain = new URL(server).hostname; setStatus('Registering...'); const transportOptions = { server: server, keepAliveInterval: 30, }; const uri = SIP.UserAgent.makeURI(`sip:${ext}@${domain}`); const userAgentOptions = { authorizationPassword: pass, authorizationUsername: ext, transportOptions: transportOptions, uri: uri, sessionDescriptionHandlerFactoryOptions: { peerConnectionConfiguration: { iceServers: [ { urls: 'stun:stun.l.google.com:19302' }, { urls: turnServer, username: 'webrtc', credential: turnPass, } ] } }, delegate: { onInvite: handleIncomingCall, } }; userAgent = new SIP.UserAgent(userAgentOptions); const registerer = new SIP.Registerer(userAgent); userAgent.start().then(() => { registerer.register(); setStatus(`Registered as ${ext}`); document.getElementById('configPanel').style.display = 'none'; }).catch(err => { setStatus(`Registration failed: ${err.message}`, true); console.error('Registration error:', err); }); } function handleIncomingCall(invitation) { const caller = invitation.remoteIdentity.displayName || invitation.remoteIdentity.uri.user || 'Unknown'; display.textContent = `Incoming: ${caller}`; setStatus('Ringing...'); // Auto-answer (in production, show accept/reject buttons) currentSession = invitation; setupSessionHandlers(invitation); invitation.accept({ sessionDescriptionHandlerOptions: { constraints: { audio: true, video: false } } }); btnCall.disabled = true; btnHangup.disabled = false; } function makeCall() { if (!userAgent || !dialedNumber) return; const domain = userAgent.configuration.uri.host; const target = SIP.UserAgent.makeURI(`sip:${dialedNumber}@${domain}`); setStatus(`Calling ${dialedNumber}...`); const inviter = new SIP.Inviter(userAgent, target, { sessionDescriptionHandlerOptions: { constraints: { audio: true, video: false } } }); currentSession = inviter; setupSessionHandlers(inviter); inviter.invite(); btnCall.disabled = true; btnHangup.disabled = false; } function setupSessionHandlers(session) { session.stateChange.addListener((state) => { switch (state) { case SIP.SessionState.Establishing: setStatus('Connecting...'); break; case SIP.SessionState.Established: setStatus('In Call'); // Attach remote audio const remoteStream = new MediaStream(); session.sessionDescriptionHandler .peerConnection.getReceivers() .forEach(receiver => { if (receiver.track) { remoteStream.addTrack(receiver.track); } }); document.getElementById('remoteAudio').srcObject = remoteStream; break; case SIP.SessionState.Terminated: setStatus('Call Ended'); display.textContent = 'Ready'; currentSession = null; dialedNumber = ''; btnCall.disabled = false; btnHangup.disabled = true; break; } }); } function hangUp() { if (!currentSession) return; switch (currentSession.state) { case SIP.SessionState.Initial: case SIP.SessionState.Establishing: currentSession.cancel(); break; case SIP.SessionState.Established: currentSession.bye(); break; } currentSession = null; dialedNumber = ''; display.textContent = 'Ready'; setStatus('Ready'); btnCall.disabled = false; btnHangup.disabled = true; } // Clear display on double-click display.addEventListener('dblclick', () => { dialedNumber = ''; display.textContent = 'Ready'; }); </script>
</body>
</html>
<!DOCTYPE html>
<html lang="en">
<head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Web Phone</title> <script src="https://unpkg.com/[email protected]/lib/platform/web/sip.js"></script> <style> * { box-sizing: border-box; margin: 0; padding: 0; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; display: flex; justify-content: center; align-items: center; min-height: 100vh; background: #1a1a2e; } .phone { background: #16213e; border-radius: 20px; padding: 30px; width: 320px; box-shadow: 0 20px 60px rgba(0,0,0,0.5); } .display { background: #0f3460; border-radius: 10px; padding: 15px; margin-bottom: 20px; text-align: center; } .display .number { color: #e94560; font-size: 24px; font-weight: bold; min-height: 36px; word-break: break-all; } .display .status { color: #a0a0a0; font-size: 12px; margin-top: 5px; } .keypad { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin-bottom: 20px; } .key { background: #0f3460; border: none; color: white; font-size: 22px; padding: 18px; border-radius: 10px; cursor: pointer; transition: background 0.2s; } .key:hover { background: #1a5276; } .key:active { background: #e94560; } .key .sub { display: block; font-size: 10px; color: #666; } .actions { display: flex; gap: 10px; } .btn { flex: 1; padding: 15px; border: none; border-radius: 10px; font-size: 16px; font-weight: bold; cursor: pointer; } .btn-call { background: #27ae60; color: white; } .btn-call:hover { background: #2ecc71; } .btn-hangup { background: #e74c3c; color: white; } .btn-hangup:hover { background: #c0392b; } .btn:disabled { opacity: 0.3; cursor: not-allowed; } .config { margin-top: 15px; padding-top: 15px; border-top: 1px solid #0f3460; } .config input { width: 100%; padding: 8px; margin: 3px 0; background: #0f3460; border: 1px solid #1a5276; color: white; border-radius: 5px; font-size: 12px; } .config label { color: #666; font-size: 11px; } .config button { width: 100%; padding: 10px; margin-top: 8px; background: #3498db; color: white; border: none; border-radius: 5px; cursor: pointer; font-size: 13px; } audio { display: none; } </style>
</head>
<body> <div class="phone"> <div class="display"> <div class="number" id="display">Ready</div> <div class="status" id="status">Not registered</div> </div> <div class="keypad"> <button class="key" onclick="press('1')">1<span class="sub"> </span></button> <button class="key" onclick="press('2')">2<span class="sub">ABC</span></button> <button class="key" onclick="press('3')">3<span class="sub">DEF</span></button> <button class="key" onclick="press('4')">4<span class="sub">GHI</span></button> <button class="key" onclick="press('5')">5<span class="sub">JKL</span></button> <button class="key" onclick="press('6')">6<span class="sub">MNO</span></button> <button class="key" onclick="press('7')">7<span class="sub">PQRS</span></button> <button class="key" onclick="press('8')">8<span class="sub">TUV</span></button> <button class="key" onclick="press('9')">9<span class="sub">WXYZ</span></button> <button class="key" onclick="press('*')">*</button> <button class="key" onclick="press('0')">0<span class="sub">+</span></button> <button class="key" onclick="press('#')">#</button> </div> <div class="actions"> <button class="btn btn-call" id="btnCall" onclick="makeCall()">Call</button> <button class="btn btn-hangup" id="btnHangup" onclick="hangUp()" disabled>Hang Up</button> </div> <div class="config" id="configPanel"> <label>Server (WSS URL)</label> <input id="cfgServer" value="wss://YOUR_DOMAIN/ws" placeholder="wss://pbx.example.com/ws"> <label>Extension</label> <input id="cfgExt" value="1010" placeholder="1010"> <label>Password</label> <input id="cfgPass" type="password" value="" placeholder="SIP password"> <label>TURN Server</label> <input id="cfgTurn" value="turn:YOUR_DOMAIN:3478" placeholder="turn:example.com:3478"> <label>TURN Password</label> <input id="cfgTurnPass" type="password" value="" placeholder="TURN shared secret"> <button onclick="register()">Register</button> </div> <audio id="remoteAudio" autoplay></audio> <audio id="localAudio" autoplay muted></audio> </div> <script> let userAgent = null; let currentSession = null; let dialedNumber = ''; const display = document.getElementById('display'); const status = document.getElementById('status'); const btnCall = document.getElementById('btnCall'); const btnHangup = document.getElementById('btnHangup'); function press(digit) { dialedNumber += digit; display.textContent = dialedNumber; // Send DTMF if in-call if (currentSession) { currentSession.sessionDescriptionHandler .sendDtmf(digit); } } function setStatus(text, isError = false) { status.textContent = text; status.style.color = isError ? '#e74c3c' : '#a0a0a0'; } function register() { const server = document.getElementById('cfgServer').value; const ext = document.getElementById('cfgExt').value; const pass = document.getElementById('cfgPass').value; const turnServer = document.getElementById('cfgTurn').value; const turnPass = document.getElementById('cfgTurnPass').value; // Extract domain from WSS URL const domain = new URL(server).hostname; setStatus('Registering...'); const transportOptions = { server: server, keepAliveInterval: 30, }; const uri = SIP.UserAgent.makeURI(`sip:${ext}@${domain}`); const userAgentOptions = { authorizationPassword: pass, authorizationUsername: ext, transportOptions: transportOptions, uri: uri, sessionDescriptionHandlerFactoryOptions: { peerConnectionConfiguration: { iceServers: [ { urls: 'stun:stun.l.google.com:19302' }, { urls: turnServer, username: 'webrtc', credential: turnPass, } ] } }, delegate: { onInvite: handleIncomingCall, } }; userAgent = new SIP.UserAgent(userAgentOptions); const registerer = new SIP.Registerer(userAgent); userAgent.start().then(() => { registerer.register(); setStatus(`Registered as ${ext}`); document.getElementById('configPanel').style.display = 'none'; }).catch(err => { setStatus(`Registration failed: ${err.message}`, true); console.error('Registration error:', err); }); } function handleIncomingCall(invitation) { const caller = invitation.remoteIdentity.displayName || invitation.remoteIdentity.uri.user || 'Unknown'; display.textContent = `Incoming: ${caller}`; setStatus('Ringing...'); // Auto-answer (in production, show accept/reject buttons) currentSession = invitation; setupSessionHandlers(invitation); invitation.accept({ sessionDescriptionHandlerOptions: { constraints: { audio: true, video: false } } }); btnCall.disabled = true; btnHangup.disabled = false; } function makeCall() { if (!userAgent || !dialedNumber) return; const domain = userAgent.configuration.uri.host; const target = SIP.UserAgent.makeURI(`sip:${dialedNumber}@${domain}`); setStatus(`Calling ${dialedNumber}...`); const inviter = new SIP.Inviter(userAgent, target, { sessionDescriptionHandlerOptions: { constraints: { audio: true, video: false } } }); currentSession = inviter; setupSessionHandlers(inviter); inviter.invite(); btnCall.disabled = true; btnHangup.disabled = false; } function setupSessionHandlers(session) { session.stateChange.addListener((state) => { switch (state) { case SIP.SessionState.Establishing: setStatus('Connecting...'); break; case SIP.SessionState.Established: setStatus('In Call'); // Attach remote audio const remoteStream = new MediaStream(); session.sessionDescriptionHandler .peerConnection.getReceivers() .forEach(receiver => { if (receiver.track) { remoteStream.addTrack(receiver.track); } }); document.getElementById('remoteAudio').srcObject = remoteStream; break; case SIP.SessionState.Terminated: setStatus('Call Ended'); display.textContent = 'Ready'; currentSession = null; dialedNumber = ''; btnCall.disabled = false; btnHangup.disabled = true; break; } }); } function hangUp() { if (!currentSession) return; switch (currentSession.state) { case SIP.SessionState.Initial: case SIP.SessionState.Establishing: currentSession.cancel(); break; case SIP.SessionState.Established: currentSession.bye(); break; } currentSession = null; dialedNumber = ''; display.textContent = 'Ready'; setStatus('Ready'); btnCall.disabled = false; btnHangup.disabled = true; } // Clear display on double-click display.addEventListener('dblclick', () => { dialedNumber = ''; display.textContent = 'Ready'; }); </script>
</body>
</html>
<!DOCTYPE html>
<html lang="en">
<head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Web Phone</title> <script src="https://unpkg.com/[email protected]/lib/platform/web/sip.js"></script> <style> * { box-sizing: border-box; margin: 0; padding: 0; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; display: flex; justify-content: center; align-items: center; min-height: 100vh; background: #1a1a2e; } .phone { background: #16213e; border-radius: 20px; padding: 30px; width: 320px; box-shadow: 0 20px 60px rgba(0,0,0,0.5); } .display { background: #0f3460; border-radius: 10px; padding: 15px; margin-bottom: 20px; text-align: center; } .display .number { color: #e94560; font-size: 24px; font-weight: bold; min-height: 36px; word-break: break-all; } .display .status { color: #a0a0a0; font-size: 12px; margin-top: 5px; } .keypad { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin-bottom: 20px; } .key { background: #0f3460; border: none; color: white; font-size: 22px; padding: 18px; border-radius: 10px; cursor: pointer; transition: background 0.2s; } .key:hover { background: #1a5276; } .key:active { background: #e94560; } .key .sub { display: block; font-size: 10px; color: #666; } .actions { display: flex; gap: 10px; } .btn { flex: 1; padding: 15px; border: none; border-radius: 10px; font-size: 16px; font-weight: bold; cursor: pointer; } .btn-call { background: #27ae60; color: white; } .btn-call:hover { background: #2ecc71; } .btn-hangup { background: #e74c3c; color: white; } .btn-hangup:hover { background: #c0392b; } .btn:disabled { opacity: 0.3; cursor: not-allowed; } .config { margin-top: 15px; padding-top: 15px; border-top: 1px solid #0f3460; } .config input { width: 100%; padding: 8px; margin: 3px 0; background: #0f3460; border: 1px solid #1a5276; color: white; border-radius: 5px; font-size: 12px; } .config label { color: #666; font-size: 11px; } .config button { width: 100%; padding: 10px; margin-top: 8px; background: #3498db; color: white; border: none; border-radius: 5px; cursor: pointer; font-size: 13px; } audio { display: none; } </style>
</head>
<body> <div class="phone"> <div class="display"> <div class="number" id="display">Ready</div> <div class="status" id="status">Not registered</div> </div> <div class="keypad"> <button class="key" onclick="press('1')">1<span class="sub"> </span></button> <button class="key" onclick="press('2')">2<span class="sub">ABC</span></button> <button class="key" onclick="press('3')">3<span class="sub">DEF</span></button> <button class="key" onclick="press('4')">4<span class="sub">GHI</span></button> <button class="key" onclick="press('5')">5<span class="sub">JKL</span></button> <button class="key" onclick="press('6')">6<span class="sub">MNO</span></button> <button class="key" onclick="press('7')">7<span class="sub">PQRS</span></button> <button class="key" onclick="press('8')">8<span class="sub">TUV</span></button> <button class="key" onclick="press('9')">9<span class="sub">WXYZ</span></button> <button class="key" onclick="press('*')">*</button> <button class="key" onclick="press('0')">0<span class="sub">+</span></button> <button class="key" onclick="press('#')">#</button> </div> <div class="actions"> <button class="btn btn-call" id="btnCall" onclick="makeCall()">Call</button> <button class="btn btn-hangup" id="btnHangup" onclick="hangUp()" disabled>Hang Up</button> </div> <div class="config" id="configPanel"> <label>Server (WSS URL)</label> <input id="cfgServer" value="wss://YOUR_DOMAIN/ws" placeholder="wss://pbx.example.com/ws"> <label>Extension</label> <input id="cfgExt" value="1010" placeholder="1010"> <label>Password</label> <input id="cfgPass" type="password" value="" placeholder="SIP password"> <label>TURN Server</label> <input id="cfgTurn" value="turn:YOUR_DOMAIN:3478" placeholder="turn:example.com:3478"> <label>TURN Password</label> <input id="cfgTurnPass" type="password" value="" placeholder="TURN shared secret"> <button onclick="register()">Register</button> </div> <audio id="remoteAudio" autoplay></audio> <audio id="localAudio" autoplay muted></audio> </div> <script> let userAgent = null; let currentSession = null; let dialedNumber = ''; const display = document.getElementById('display'); const status = document.getElementById('status'); const btnCall = document.getElementById('btnCall'); const btnHangup = document.getElementById('btnHangup'); function press(digit) { dialedNumber += digit; display.textContent = dialedNumber; // Send DTMF if in-call if (currentSession) { currentSession.sessionDescriptionHandler .sendDtmf(digit); } } function setStatus(text, isError = false) { status.textContent = text; status.style.color = isError ? '#e74c3c' : '#a0a0a0'; } function register() { const server = document.getElementById('cfgServer').value; const ext = document.getElementById('cfgExt').value; const pass = document.getElementById('cfgPass').value; const turnServer = document.getElementById('cfgTurn').value; const turnPass = document.getElementById('cfgTurnPass').value; // Extract domain from WSS URL const domain = new URL(server).hostname; setStatus('Registering...'); const transportOptions = { server: server, keepAliveInterval: 30, }; const uri = SIP.UserAgent.makeURI(`sip:${ext}@${domain}`); const userAgentOptions = { authorizationPassword: pass, authorizationUsername: ext, transportOptions: transportOptions, uri: uri, sessionDescriptionHandlerFactoryOptions: { peerConnectionConfiguration: { iceServers: [ { urls: 'stun:stun.l.google.com:19302' }, { urls: turnServer, username: 'webrtc', credential: turnPass, } ] } }, delegate: { onInvite: handleIncomingCall, } }; userAgent = new SIP.UserAgent(userAgentOptions); const registerer = new SIP.Registerer(userAgent); userAgent.start().then(() => { registerer.register(); setStatus(`Registered as ${ext}`); document.getElementById('configPanel').style.display = 'none'; }).catch(err => { setStatus(`Registration failed: ${err.message}`, true); console.error('Registration error:', err); }); } function handleIncomingCall(invitation) { const caller = invitation.remoteIdentity.displayName || invitation.remoteIdentity.uri.user || 'Unknown'; display.textContent = `Incoming: ${caller}`; setStatus('Ringing...'); // Auto-answer (in production, show accept/reject buttons) currentSession = invitation; setupSessionHandlers(invitation); invitation.accept({ sessionDescriptionHandlerOptions: { constraints: { audio: true, video: false } } }); btnCall.disabled = true; btnHangup.disabled = false; } function makeCall() { if (!userAgent || !dialedNumber) return; const domain = userAgent.configuration.uri.host; const target = SIP.UserAgent.makeURI(`sip:${dialedNumber}@${domain}`); setStatus(`Calling ${dialedNumber}...`); const inviter = new SIP.Inviter(userAgent, target, { sessionDescriptionHandlerOptions: { constraints: { audio: true, video: false } } }); currentSession = inviter; setupSessionHandlers(inviter); inviter.invite(); btnCall.disabled = true; btnHangup.disabled = false; } function setupSessionHandlers(session) { session.stateChange.addListener((state) => { switch (state) { case SIP.SessionState.Establishing: setStatus('Connecting...'); break; case SIP.SessionState.Established: setStatus('In Call'); // Attach remote audio const remoteStream = new MediaStream(); session.sessionDescriptionHandler .peerConnection.getReceivers() .forEach(receiver => { if (receiver.track) { remoteStream.addTrack(receiver.track); } }); document.getElementById('remoteAudio').srcObject = remoteStream; break; case SIP.SessionState.Terminated: setStatus('Call Ended'); display.textContent = 'Ready'; currentSession = null; dialedNumber = ''; btnCall.disabled = false; btnHangup.disabled = true; break; } }); } function hangUp() { if (!currentSession) return; switch (currentSession.state) { case SIP.SessionState.Initial: case SIP.SessionState.Establishing: currentSession.cancel(); break; case SIP.SessionState.Established: currentSession.bye(); break; } currentSession = null; dialedNumber = ''; display.textContent = 'Ready'; setStatus('Ready'); btnCall.disabled = false; btnHangup.disabled = true; } // Clear display on double-click display.addEventListener('dblclick', () => { dialedNumber = ''; display.textContent = 'Ready'; }); </script>
</body>
</html>
# 1. Verify WebSocket transport is loaded
docker compose exec asterisk asterisk -rx "pjsip show transports" | grep wss # 2. Verify DTLS is working
docker compose exec asterisk asterisk -rx "module show like dtls" # 3. Verify Coturn is running
docker compose logs coturn | tail -5 # 4. Test TURN server connectivity
# From any machine with turnutils installed:
turnutils_uclient -T -u webrtc -w YOUR_TURN_SECRET YOUR_DOMAIN # 5. Open https://YOUR_DOMAIN in a browser
# Register with extension 1010 and the SIP password
# Call extension 1001 to test
# 1. Verify WebSocket transport is loaded
docker compose exec asterisk asterisk -rx "pjsip show transports" | grep wss # 2. Verify DTLS is working
docker compose exec asterisk asterisk -rx "module show like dtls" # 3. Verify Coturn is running
docker compose logs coturn | tail -5 # 4. Test TURN server connectivity
# From any machine with turnutils installed:
turnutils_uclient -T -u webrtc -w YOUR_TURN_SECRET YOUR_DOMAIN # 5. Open https://YOUR_DOMAIN in a browser
# Register with extension 1010 and the SIP password
# Call extension 1001 to test
# 1. Verify WebSocket transport is loaded
docker compose exec asterisk asterisk -rx "pjsip show transports" | grep wss # 2. Verify DTLS is working
docker compose exec asterisk asterisk -rx "module show like dtls" # 3. Verify Coturn is running
docker compose logs coturn | tail -5 # 4. Test TURN server connectivity
# From any machine with turnutils installed:
turnutils_uclient -T -u webrtc -w YOUR_TURN_SECRET YOUR_DOMAIN # 5. Open https://YOUR_DOMAIN in a browser
# Register with extension 1010 and the SIP password
# Call extension 1001 to test
# Check health status of all containers
docker compose ps
# NAME STATUS PORTS
# asterisk Up (healthy) ...
# asterisk-mariadb Up (healthy) ...
# asterisk-redis Up (healthy) ... # Detailed health check output
docker inspect --format='{{json .State.Health}}' asterisk | jq .
# Check health status of all containers
docker compose ps
# NAME STATUS PORTS
# asterisk Up (healthy) ...
# asterisk-mariadb Up (healthy) ...
# asterisk-redis Up (healthy) ... # Detailed health check output
docker inspect --format='{{json .State.Health}}' asterisk | jq .
# Check health status of all containers
docker compose ps
# NAME STATUS PORTS
# asterisk Up (healthy) ...
# asterisk-mariadb Up (healthy) ...
# asterisk-redis Up (healthy) ... # Detailed health check output
docker inspect --format='{{json .State.Health}}' asterisk | jq .
#!/usr/bin/env python3
"""
Asterisk Prometheus Exporter (sidecar)
Exposes Asterisk metrics at :9200/metrics Connects to Asterisk via AMI (local socket or TCP).
""" import http.server
import os
import re
import socket
import time AMI_HOST = os.getenv("AMI_HOST", "127.0.0.1")
AMI_PORT = int(os.getenv("AMI_PORT", "5038"))
AMI_USER = os.getenv("AMI_USERNAME", "ami_admin")
AMI_PASS = os.getenv("AMI_PASSWORD", "")
LISTEN_PORT = int(os.getenv("EXPORTER_PORT", "9200")) def ami_command(action: str, **kwargs) -> str: """Send an AMI command and return the response.""" try: sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.settimeout(5) sock.connect((AMI_HOST, AMI_PORT)) # Read banner sock.recv(4096) # Login login = f"Action: Login\r\nUsername: {AMI_USER}\r\nSecret: {AMI_PASS}\r\n\r\n" sock.send(login.encode()) sock.recv(4096) # Send command cmd = f"Action: {action}\r\n" for k, v in kwargs.items(): cmd += f"{k}: {v}\r\n" cmd += "\r\n" sock.send(cmd.encode()) # Read response (with timeout) response = b"" while True: try: data = sock.recv(4096) if not data: break response += data if b"\r\n\r\n" in data: break except socket.timeout: break # Logoff sock.send(b"Action: Logoff\r\n\r\n") sock.close() return response.decode("utf-8", errors="replace") except Exception as e: return f"Error: {e}" def collect_metrics() -> str: """Collect all metrics and return Prometheus text format.""" lines = [] # ---- Active channels ---- resp = ami_command("Command", Command="core show channels count") match = re.search(r"(\d+) active channel", resp) channels = int(match.group(1)) if match else 0 match2 = re.search(r"(\d+) active call", resp) calls = int(match2.group(1)) if match2 else 0 lines.append("# HELP asterisk_active_channels Number of active channels") lines.append("# TYPE asterisk_active_channels gauge") lines.append(f"asterisk_active_channels {channels}") lines.append("# HELP asterisk_active_calls Number of active calls") lines.append("# TYPE asterisk_active_calls gauge") lines.append(f"asterisk_active_calls {calls}") # ---- PJSIP endpoints ---- resp = ami_command("Command", Command="pjsip show endpoints") available = len(re.findall(r"Avail\b", resp)) unavailable = len(re.findall(r"Unavail\b", resp)) lines.append("# HELP asterisk_pjsip_endpoints_available Available PJSIP endpoints") lines.append("# TYPE asterisk_pjsip_endpoints_available gauge") lines.append(f"asterisk_pjsip_endpoints_available {available}") lines.append("# HELP asterisk_pjsip_endpoints_unavailable Unavailable PJSIP endpoints") lines.append("# TYPE asterisk_pjsip_endpoints_unavailable gauge") lines.append(f"asterisk_pjsip_endpoints_unavailable {unavailable}") # ---- Uptime ---- resp = ami_command("Command", Command="core show uptime seconds") match = re.search(r"System uptime:\s+(\d+)", resp) uptime = int(match.group(1)) if match else 0 match2 = re.search(r"Last reload:\s+(\d+)", resp) reload_time = int(match2.group(1)) if match2 else 0 lines.append("# HELP asterisk_uptime_seconds System uptime in seconds") lines.append("# TYPE asterisk_uptime_seconds gauge") lines.append(f"asterisk_uptime_seconds {uptime}") # ---- SIP registrations ---- resp = ami_command("Command", Command="pjsip show registrations") registered = len(re.findall(r"Registered\b", resp)) unregistered = len(re.findall(r"Unregistered\b", resp)) lines.append("# HELP asterisk_pjsip_registrations_registered Registered trunk count") lines.append("# TYPE asterisk_pjsip_registrations_registered gauge") lines.append(f"asterisk_pjsip_registrations_registered {registered}") lines.append("# HELP asterisk_pjsip_registrations_unregistered Unregistered trunk count") lines.append("# TYPE asterisk_pjsip_registrations_unregistered gauge") lines.append(f"asterisk_pjsip_registrations_unregistered {unregistered}") # ---- ConfBridge conferences ---- resp = ami_command("Command", Command="confbridge list") conferences = len(re.findall(r"Conference\s+\d+", resp)) lines.append("# HELP asterisk_confbridge_active Active conference rooms") lines.append("# TYPE asterisk_confbridge_active gauge") lines.append(f"asterisk_confbridge_active {conferences}") return "\n".join(lines) + "\n" class MetricsHandler(http.server.BaseHTTPRequestHandler): """HTTP handler for /metrics endpoint.""" def do_GET(self): if self.path == "/metrics": metrics = collect_metrics() self.send_response(200) self.send_header("Content-Type", "text/plain; version=0.0.4") self.end_headers() self.wfile.write(metrics.encode()) elif self.path == "/health": self.send_response(200) self.end_headers() self.wfile.write(b"OK\n") else: self.send_response(404) self.end_headers() def log_message(self, format, *args): pass # Suppress access logs if __name__ == "__main__": print(f"Asterisk Prometheus Exporter listening on :{LISTEN_PORT}") server = http.server.HTTPServer(("0.0.0.0", LISTEN_PORT), MetricsHandler) server.serve_forever()
#!/usr/bin/env python3
"""
Asterisk Prometheus Exporter (sidecar)
Exposes Asterisk metrics at :9200/metrics Connects to Asterisk via AMI (local socket or TCP).
""" import http.server
import os
import re
import socket
import time AMI_HOST = os.getenv("AMI_HOST", "127.0.0.1")
AMI_PORT = int(os.getenv("AMI_PORT", "5038"))
AMI_USER = os.getenv("AMI_USERNAME", "ami_admin")
AMI_PASS = os.getenv("AMI_PASSWORD", "")
LISTEN_PORT = int(os.getenv("EXPORTER_PORT", "9200")) def ami_command(action: str, **kwargs) -> str: """Send an AMI command and return the response.""" try: sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.settimeout(5) sock.connect((AMI_HOST, AMI_PORT)) # Read banner sock.recv(4096) # Login login = f"Action: Login\r\nUsername: {AMI_USER}\r\nSecret: {AMI_PASS}\r\n\r\n" sock.send(login.encode()) sock.recv(4096) # Send command cmd = f"Action: {action}\r\n" for k, v in kwargs.items(): cmd += f"{k}: {v}\r\n" cmd += "\r\n" sock.send(cmd.encode()) # Read response (with timeout) response = b"" while True: try: data = sock.recv(4096) if not data: break response += data if b"\r\n\r\n" in data: break except socket.timeout: break # Logoff sock.send(b"Action: Logoff\r\n\r\n") sock.close() return response.decode("utf-8", errors="replace") except Exception as e: return f"Error: {e}" def collect_metrics() -> str: """Collect all metrics and return Prometheus text format.""" lines = [] # ---- Active channels ---- resp = ami_command("Command", Command="core show channels count") match = re.search(r"(\d+) active channel", resp) channels = int(match.group(1)) if match else 0 match2 = re.search(r"(\d+) active call", resp) calls = int(match2.group(1)) if match2 else 0 lines.append("# HELP asterisk_active_channels Number of active channels") lines.append("# TYPE asterisk_active_channels gauge") lines.append(f"asterisk_active_channels {channels}") lines.append("# HELP asterisk_active_calls Number of active calls") lines.append("# TYPE asterisk_active_calls gauge") lines.append(f"asterisk_active_calls {calls}") # ---- PJSIP endpoints ---- resp = ami_command("Command", Command="pjsip show endpoints") available = len(re.findall(r"Avail\b", resp)) unavailable = len(re.findall(r"Unavail\b", resp)) lines.append("# HELP asterisk_pjsip_endpoints_available Available PJSIP endpoints") lines.append("# TYPE asterisk_pjsip_endpoints_available gauge") lines.append(f"asterisk_pjsip_endpoints_available {available}") lines.append("# HELP asterisk_pjsip_endpoints_unavailable Unavailable PJSIP endpoints") lines.append("# TYPE asterisk_pjsip_endpoints_unavailable gauge") lines.append(f"asterisk_pjsip_endpoints_unavailable {unavailable}") # ---- Uptime ---- resp = ami_command("Command", Command="core show uptime seconds") match = re.search(r"System uptime:\s+(\d+)", resp) uptime = int(match.group(1)) if match else 0 match2 = re.search(r"Last reload:\s+(\d+)", resp) reload_time = int(match2.group(1)) if match2 else 0 lines.append("# HELP asterisk_uptime_seconds System uptime in seconds") lines.append("# TYPE asterisk_uptime_seconds gauge") lines.append(f"asterisk_uptime_seconds {uptime}") # ---- SIP registrations ---- resp = ami_command("Command", Command="pjsip show registrations") registered = len(re.findall(r"Registered\b", resp)) unregistered = len(re.findall(r"Unregistered\b", resp)) lines.append("# HELP asterisk_pjsip_registrations_registered Registered trunk count") lines.append("# TYPE asterisk_pjsip_registrations_registered gauge") lines.append(f"asterisk_pjsip_registrations_registered {registered}") lines.append("# HELP asterisk_pjsip_registrations_unregistered Unregistered trunk count") lines.append("# TYPE asterisk_pjsip_registrations_unregistered gauge") lines.append(f"asterisk_pjsip_registrations_unregistered {unregistered}") # ---- ConfBridge conferences ---- resp = ami_command("Command", Command="confbridge list") conferences = len(re.findall(r"Conference\s+\d+", resp)) lines.append("# HELP asterisk_confbridge_active Active conference rooms") lines.append("# TYPE asterisk_confbridge_active gauge") lines.append(f"asterisk_confbridge_active {conferences}") return "\n".join(lines) + "\n" class MetricsHandler(http.server.BaseHTTPRequestHandler): """HTTP handler for /metrics endpoint.""" def do_GET(self): if self.path == "/metrics": metrics = collect_metrics() self.send_response(200) self.send_header("Content-Type", "text/plain; version=0.0.4") self.end_headers() self.wfile.write(metrics.encode()) elif self.path == "/health": self.send_response(200) self.end_headers() self.wfile.write(b"OK\n") else: self.send_response(404) self.end_headers() def log_message(self, format, *args): pass # Suppress access logs if __name__ == "__main__": print(f"Asterisk Prometheus Exporter listening on :{LISTEN_PORT}") server = http.server.HTTPServer(("0.0.0.0", LISTEN_PORT), MetricsHandler) server.serve_forever()
#!/usr/bin/env python3
"""
Asterisk Prometheus Exporter (sidecar)
Exposes Asterisk metrics at :9200/metrics Connects to Asterisk via AMI (local socket or TCP).
""" import http.server
import os
import re
import socket
import time AMI_HOST = os.getenv("AMI_HOST", "127.0.0.1")
AMI_PORT = int(os.getenv("AMI_PORT", "5038"))
AMI_USER = os.getenv("AMI_USERNAME", "ami_admin")
AMI_PASS = os.getenv("AMI_PASSWORD", "")
LISTEN_PORT = int(os.getenv("EXPORTER_PORT", "9200")) def ami_command(action: str, **kwargs) -> str: """Send an AMI command and return the response.""" try: sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.settimeout(5) sock.connect((AMI_HOST, AMI_PORT)) # Read banner sock.recv(4096) # Login login = f"Action: Login\r\nUsername: {AMI_USER}\r\nSecret: {AMI_PASS}\r\n\r\n" sock.send(login.encode()) sock.recv(4096) # Send command cmd = f"Action: {action}\r\n" for k, v in kwargs.items(): cmd += f"{k}: {v}\r\n" cmd += "\r\n" sock.send(cmd.encode()) # Read response (with timeout) response = b"" while True: try: data = sock.recv(4096) if not data: break response += data if b"\r\n\r\n" in data: break except socket.timeout: break # Logoff sock.send(b"Action: Logoff\r\n\r\n") sock.close() return response.decode("utf-8", errors="replace") except Exception as e: return f"Error: {e}" def collect_metrics() -> str: """Collect all metrics and return Prometheus text format.""" lines = [] # ---- Active channels ---- resp = ami_command("Command", Command="core show channels count") match = re.search(r"(\d+) active channel", resp) channels = int(match.group(1)) if match else 0 match2 = re.search(r"(\d+) active call", resp) calls = int(match2.group(1)) if match2 else 0 lines.append("# HELP asterisk_active_channels Number of active channels") lines.append("# TYPE asterisk_active_channels gauge") lines.append(f"asterisk_active_channels {channels}") lines.append("# HELP asterisk_active_calls Number of active calls") lines.append("# TYPE asterisk_active_calls gauge") lines.append(f"asterisk_active_calls {calls}") # ---- PJSIP endpoints ---- resp = ami_command("Command", Command="pjsip show endpoints") available = len(re.findall(r"Avail\b", resp)) unavailable = len(re.findall(r"Unavail\b", resp)) lines.append("# HELP asterisk_pjsip_endpoints_available Available PJSIP endpoints") lines.append("# TYPE asterisk_pjsip_endpoints_available gauge") lines.append(f"asterisk_pjsip_endpoints_available {available}") lines.append("# HELP asterisk_pjsip_endpoints_unavailable Unavailable PJSIP endpoints") lines.append("# TYPE asterisk_pjsip_endpoints_unavailable gauge") lines.append(f"asterisk_pjsip_endpoints_unavailable {unavailable}") # ---- Uptime ---- resp = ami_command("Command", Command="core show uptime seconds") match = re.search(r"System uptime:\s+(\d+)", resp) uptime = int(match.group(1)) if match else 0 match2 = re.search(r"Last reload:\s+(\d+)", resp) reload_time = int(match2.group(1)) if match2 else 0 lines.append("# HELP asterisk_uptime_seconds System uptime in seconds") lines.append("# TYPE asterisk_uptime_seconds gauge") lines.append(f"asterisk_uptime_seconds {uptime}") # ---- SIP registrations ---- resp = ami_command("Command", Command="pjsip show registrations") registered = len(re.findall(r"Registered\b", resp)) unregistered = len(re.findall(r"Unregistered\b", resp)) lines.append("# HELP asterisk_pjsip_registrations_registered Registered trunk count") lines.append("# TYPE asterisk_pjsip_registrations_registered gauge") lines.append(f"asterisk_pjsip_registrations_registered {registered}") lines.append("# HELP asterisk_pjsip_registrations_unregistered Unregistered trunk count") lines.append("# TYPE asterisk_pjsip_registrations_unregistered gauge") lines.append(f"asterisk_pjsip_registrations_unregistered {unregistered}") # ---- ConfBridge conferences ---- resp = ami_command("Command", Command="confbridge list") conferences = len(re.findall(r"Conference\s+\d+", resp)) lines.append("# HELP asterisk_confbridge_active Active conference rooms") lines.append("# TYPE asterisk_confbridge_active gauge") lines.append(f"asterisk_confbridge_active {conferences}") return "\n".join(lines) + "\n" class MetricsHandler(http.server.BaseHTTPRequestHandler): """HTTP handler for /metrics endpoint.""" def do_GET(self): if self.path == "/metrics": metrics = collect_metrics() self.send_response(200) self.send_header("Content-Type", "text/plain; version=0.0.4") self.end_headers() self.wfile.write(metrics.encode()) elif self.path == "/health": self.send_response(200) self.end_headers() self.wfile.write(b"OK\n") else: self.send_response(404) self.end_headers() def log_message(self, format, *args): pass # Suppress access logs if __name__ == "__main__": print(f"Asterisk Prometheus Exporter listening on :{LISTEN_PORT}") server = http.server.HTTPServer(("0.0.0.0", LISTEN_PORT), MetricsHandler) server.serve_forever()
; =============================================================================
; AMI (Asterisk Manager Interface) Configuration
; Used by Prometheus exporter and management tools
; ============================================================================= [general]
enabled = yes
port = 5038
bindaddr = 0.0.0.0 [${AMI_USERNAME}]
secret = ${AMI_PASSWORD}
deny = 0.0.0.0/0.0.0.0
permit = 127.0.0.1/255.255.255.0
permit = 172.25.0.0/255.255.255.0
read = system,call,log,command,agent,user,config,dtmf,reporting,cdr,dialplan
write = system,call,command,originate,reporting
writetimeout = 5000
; =============================================================================
; AMI (Asterisk Manager Interface) Configuration
; Used by Prometheus exporter and management tools
; ============================================================================= [general]
enabled = yes
port = 5038
bindaddr = 0.0.0.0 [${AMI_USERNAME}]
secret = ${AMI_PASSWORD}
deny = 0.0.0.0/0.0.0.0
permit = 127.0.0.1/255.255.255.0
permit = 172.25.0.0/255.255.255.0
read = system,call,log,command,agent,user,config,dtmf,reporting,cdr,dialplan
write = system,call,command,originate,reporting
writetimeout = 5000
; =============================================================================
; AMI (Asterisk Manager Interface) Configuration
; Used by Prometheus exporter and management tools
; ============================================================================= [general]
enabled = yes
port = 5038
bindaddr = 0.0.0.0 [${AMI_USERNAME}]
secret = ${AMI_PASSWORD}
deny = 0.0.0.0/0.0.0.0
permit = 127.0.0.1/255.255.255.0
permit = 172.25.0.0/255.255.255.0
read = system,call,log,command,agent,user,config,dtmf,reporting,cdr,dialplan
write = system,call,command,originate,reporting
writetimeout = 5000
# --------------------------------------------------------------------------- # Promtail - Log Shipping to Loki # --------------------------------------------------------------------------- promtail: image: grafana/promtail:latest container_name: asterisk-promtail restart: unless-stopped networks: - asterisk-net volumes: - asterisk-logs:/var/log/asterisk:ro - ./promtail/config.yml:/etc/promtail/config.yml:ro command: -config.file=/etc/promtail/config.yml deploy: resources: limits: cpus: '0.25' memory: 128M
# --------------------------------------------------------------------------- # Promtail - Log Shipping to Loki # --------------------------------------------------------------------------- promtail: image: grafana/promtail:latest container_name: asterisk-promtail restart: unless-stopped networks: - asterisk-net volumes: - asterisk-logs:/var/log/asterisk:ro - ./promtail/config.yml:/etc/promtail/config.yml:ro command: -config.file=/etc/promtail/config.yml deploy: resources: limits: cpus: '0.25' memory: 128M
# --------------------------------------------------------------------------- # Promtail - Log Shipping to Loki # --------------------------------------------------------------------------- promtail: image: grafana/promtail:latest container_name: asterisk-promtail restart: unless-stopped networks: - asterisk-net volumes: - asterisk-logs:/var/log/asterisk:ro - ./promtail/config.yml:/etc/promtail/config.yml:ro command: -config.file=/etc/promtail/config.yml deploy: resources: limits: cpus: '0.25' memory: 128M
# =============================================================================
# Promtail Configuration - Ship Asterisk Logs to Loki
# ============================================================================= server: http_listen_port: 9080 grpc_listen_port: 0 positions: filename: /tmp/positions.yaml clients: # Point to your Loki instance - url: http://YOUR_LOKI_HOST:3100/loki/api/v1/push scrape_configs: # Asterisk messages log - job_name: asterisk-messages static_configs: - targets: - localhost labels: job: asterisk host: asterisk-docker __path__: /var/log/asterisk/messages pipeline_stages: - regex: expression: '^\[(?P<timestamp>[^\]]+)\]\s+(?P<level>\w+)\[(?P<thread>\d+)\]\s+(?P<module>[^:]+):\s+(?P<message>.*)$' - labels: level: module: - timestamp: source: timestamp format: "2006-01-02 15:04:05.000" # Asterisk security log - job_name: asterisk-security static_configs: - targets: - localhost labels: job: asterisk-security host: asterisk-docker __path__: /var/log/asterisk/security
# =============================================================================
# Promtail Configuration - Ship Asterisk Logs to Loki
# ============================================================================= server: http_listen_port: 9080 grpc_listen_port: 0 positions: filename: /tmp/positions.yaml clients: # Point to your Loki instance - url: http://YOUR_LOKI_HOST:3100/loki/api/v1/push scrape_configs: # Asterisk messages log - job_name: asterisk-messages static_configs: - targets: - localhost labels: job: asterisk host: asterisk-docker __path__: /var/log/asterisk/messages pipeline_stages: - regex: expression: '^\[(?P<timestamp>[^\]]+)\]\s+(?P<level>\w+)\[(?P<thread>\d+)\]\s+(?P<module>[^:]+):\s+(?P<message>.*)$' - labels: level: module: - timestamp: source: timestamp format: "2006-01-02 15:04:05.000" # Asterisk security log - job_name: asterisk-security static_configs: - targets: - localhost labels: job: asterisk-security host: asterisk-docker __path__: /var/log/asterisk/security
# =============================================================================
# Promtail Configuration - Ship Asterisk Logs to Loki
# ============================================================================= server: http_listen_port: 9080 grpc_listen_port: 0 positions: filename: /tmp/positions.yaml clients: # Point to your Loki instance - url: http://YOUR_LOKI_HOST:3100/loki/api/v1/push scrape_configs: # Asterisk messages log - job_name: asterisk-messages static_configs: - targets: - localhost labels: job: asterisk host: asterisk-docker __path__: /var/log/asterisk/messages pipeline_stages: - regex: expression: '^\[(?P<timestamp>[^\]]+)\]\s+(?P<level>\w+)\[(?P<thread>\d+)\]\s+(?P<module>[^:]+):\s+(?P<message>.*)$' - labels: level: module: - timestamp: source: timestamp format: "2006-01-02 15:04:05.000" # Asterisk security log - job_name: asterisk-security static_configs: - targets: - localhost labels: job: asterisk-security host: asterisk-docker __path__: /var/log/asterisk/security
┌──────────────┐ │ Kamailio │ SIP Load Balancer / Proxy │ (SIP Proxy) │ - Distributes calls by hash or weight └──────┬───────┘ - Health checks Asterisk instances │ - Handles registration (single point) ┌────────────┼────────────┐ │ │ │ ┌────────▼───┐ ┌──────▼─────┐ ┌───▼────────┐ │ Asterisk 1 │ │ Asterisk 2 │ │ Asterisk 3 │ │ (Container)│ │ (Container)│ │ (Container)│ └─────┬──────┘ └─────┬──────┘ └─────┬──────┘ │ │ │ ┌─────▼───────────────▼───────────────▼─────┐ │ Shared Services │ │ MariaDB (Primary + Replica) │ │ Redis Cluster │ │ NFS/S3 (Recordings) │ └────────────────────────────────────────────┘
┌──────────────┐ │ Kamailio │ SIP Load Balancer / Proxy │ (SIP Proxy) │ - Distributes calls by hash or weight └──────┬───────┘ - Health checks Asterisk instances │ - Handles registration (single point) ┌────────────┼────────────┐ │ │ │ ┌────────▼───┐ ┌──────▼─────┐ ┌───▼────────┐ │ Asterisk 1 │ │ Asterisk 2 │ │ Asterisk 3 │ │ (Container)│ │ (Container)│ │ (Container)│ └─────┬──────┘ └─────┬──────┘ └─────┬──────┘ │ │ │ ┌─────▼───────────────▼───────────────▼─────┐ │ Shared Services │ │ MariaDB (Primary + Replica) │ │ Redis Cluster │ │ NFS/S3 (Recordings) │ └────────────────────────────────────────────┘
┌──────────────┐ │ Kamailio │ SIP Load Balancer / Proxy │ (SIP Proxy) │ - Distributes calls by hash or weight └──────┬───────┘ - Health checks Asterisk instances │ - Handles registration (single point) ┌────────────┼────────────┐ │ │ │ ┌────────▼───┐ ┌──────▼─────┐ ┌───▼────────┐ │ Asterisk 1 │ │ Asterisk 2 │ │ Asterisk 3 │ │ (Container)│ │ (Container)│ │ (Container)│ └─────┬──────┘ └─────┬──────┘ └─────┬──────┘ │ │ │ ┌─────▼───────────────▼───────────────▼─────┐ │ Shared Services │ │ MariaDB (Primary + Replica) │ │ Redis Cluster │ │ NFS/S3 (Recordings) │ └────────────────────────────────────────────┘
# docker-compose.scale.yml - Multi-instance Asterisk
# Usage: docker compose -f docker-compose.scale.yml up -d --scale asterisk=3 services: asterisk: build: ./asterisk # NO ports mapping - Kamailio handles external traffic networks: - asterisk-net environment: - PJSIP_EXTERNAL_IP=${PJSIP_EXTERNAL_IP} - DB_HOST=mariadb-primary # Each instance gets unique name via Docker volumes: - ./asterisk/configs:/etc/asterisk/templates:ro - recordings-nfs:/var/spool/asterisk/monitor depends_on: - mariadb-primary - redis deploy: replicas: 3 resources: limits: cpus: '2' memory: 2G kamailio: image: kamailio/kamailio:latest ports: - "5060:5060/udp" - "5060:5060/tcp" - "5061:5061/tcp" volumes: - ./kamailio/kamailio.cfg:/etc/kamailio/kamailio.cfg:ro networks: - asterisk-net mariadb-primary: image: mariadb:11.4 environment: - MARIADB_ROOT_PASSWORD=${DB_ROOT_PASSWORD} - MARIADB_DATABASE=${DB_NAME} - MARIADB_USER=${DB_USER} - MARIADB_PASSWORD=${DB_PASSWORD} - MARIADB_REPLICATION_MODE=master volumes: - mariadb-primary-data:/var/lib/mysql networks: - asterisk-net mariadb-replica: image: mariadb:11.4 environment: - MARIADB_REPLICATION_MODE=slave - MARIADB_MASTER_HOST=mariadb-primary - MARIADB_MASTER_ROOT_PASSWORD=${DB_ROOT_PASSWORD} volumes: - mariadb-replica-data:/var/lib/mysql depends_on: - mariadb-primary networks: - asterisk-net redis: image: redis:7-alpine command: redis-server --requirepass ${REDIS_PASSWORD} --appendonly yes volumes: - redis-data:/data networks: - asterisk-net volumes: mariadb-primary-data: mariadb-replica-data: redis-data: recordings-nfs: driver: local driver_opts: type: nfs o: "addr=NFS_SERVER_IP,nolock,soft,rw" device: ":/exports/asterisk-recordings" networks: asterisk-net: driver: bridge
# docker-compose.scale.yml - Multi-instance Asterisk
# Usage: docker compose -f docker-compose.scale.yml up -d --scale asterisk=3 services: asterisk: build: ./asterisk # NO ports mapping - Kamailio handles external traffic networks: - asterisk-net environment: - PJSIP_EXTERNAL_IP=${PJSIP_EXTERNAL_IP} - DB_HOST=mariadb-primary # Each instance gets unique name via Docker volumes: - ./asterisk/configs:/etc/asterisk/templates:ro - recordings-nfs:/var/spool/asterisk/monitor depends_on: - mariadb-primary - redis deploy: replicas: 3 resources: limits: cpus: '2' memory: 2G kamailio: image: kamailio/kamailio:latest ports: - "5060:5060/udp" - "5060:5060/tcp" - "5061:5061/tcp" volumes: - ./kamailio/kamailio.cfg:/etc/kamailio/kamailio.cfg:ro networks: - asterisk-net mariadb-primary: image: mariadb:11.4 environment: - MARIADB_ROOT_PASSWORD=${DB_ROOT_PASSWORD} - MARIADB_DATABASE=${DB_NAME} - MARIADB_USER=${DB_USER} - MARIADB_PASSWORD=${DB_PASSWORD} - MARIADB_REPLICATION_MODE=master volumes: - mariadb-primary-data:/var/lib/mysql networks: - asterisk-net mariadb-replica: image: mariadb:11.4 environment: - MARIADB_REPLICATION_MODE=slave - MARIADB_MASTER_HOST=mariadb-primary - MARIADB_MASTER_ROOT_PASSWORD=${DB_ROOT_PASSWORD} volumes: - mariadb-replica-data:/var/lib/mysql depends_on: - mariadb-primary networks: - asterisk-net redis: image: redis:7-alpine command: redis-server --requirepass ${REDIS_PASSWORD} --appendonly yes volumes: - redis-data:/data networks: - asterisk-net volumes: mariadb-primary-data: mariadb-replica-data: redis-data: recordings-nfs: driver: local driver_opts: type: nfs o: "addr=NFS_SERVER_IP,nolock,soft,rw" device: ":/exports/asterisk-recordings" networks: asterisk-net: driver: bridge
# docker-compose.scale.yml - Multi-instance Asterisk
# Usage: docker compose -f docker-compose.scale.yml up -d --scale asterisk=3 services: asterisk: build: ./asterisk # NO ports mapping - Kamailio handles external traffic networks: - asterisk-net environment: - PJSIP_EXTERNAL_IP=${PJSIP_EXTERNAL_IP} - DB_HOST=mariadb-primary # Each instance gets unique name via Docker volumes: - ./asterisk/configs:/etc/asterisk/templates:ro - recordings-nfs:/var/spool/asterisk/monitor depends_on: - mariadb-primary - redis deploy: replicas: 3 resources: limits: cpus: '2' memory: 2G kamailio: image: kamailio/kamailio:latest ports: - "5060:5060/udp" - "5060:5060/tcp" - "5061:5061/tcp" volumes: - ./kamailio/kamailio.cfg:/etc/kamailio/kamailio.cfg:ro networks: - asterisk-net mariadb-primary: image: mariadb:11.4 environment: - MARIADB_ROOT_PASSWORD=${DB_ROOT_PASSWORD} - MARIADB_DATABASE=${DB_NAME} - MARIADB_USER=${DB_USER} - MARIADB_PASSWORD=${DB_PASSWORD} - MARIADB_REPLICATION_MODE=master volumes: - mariadb-primary-data:/var/lib/mysql networks: - asterisk-net mariadb-replica: image: mariadb:11.4 environment: - MARIADB_REPLICATION_MODE=slave - MARIADB_MASTER_HOST=mariadb-primary - MARIADB_MASTER_ROOT_PASSWORD=${DB_ROOT_PASSWORD} volumes: - mariadb-replica-data:/var/lib/mysql depends_on: - mariadb-primary networks: - asterisk-net redis: image: redis:7-alpine command: redis-server --requirepass ${REDIS_PASSWORD} --appendonly yes volumes: - redis-data:/data networks: - asterisk-net volumes: mariadb-primary-data: mariadb-replica-data: redis-data: recordings-nfs: driver: local driver_opts: type: nfs o: "addr=NFS_SERVER_IP,nolock,soft,rw" device: ":/exports/asterisk-recordings" networks: asterisk-net: driver: bridge
# Example: Track active calls across all instances
import redis
import json r = redis.Redis(host='redis', port=6379, password='...') # When a call starts on any instance
def on_call_start(call_id, caller, callee, instance_id): r.hset(f"active_call:{call_id}", mapping={ "caller": caller, "callee": callee, "instance": instance_id, "start_time": str(time.time()), }) r.expire(f"active_call:{call_id}", 7200) # 2-hour TTL # Query active calls across all instances
def get_active_calls(): calls = [] for key in r.scan_iter("active_call:*"): call = r.hgetall(key) calls.append(call) return calls
# Example: Track active calls across all instances
import redis
import json r = redis.Redis(host='redis', port=6379, password='...') # When a call starts on any instance
def on_call_start(call_id, caller, callee, instance_id): r.hset(f"active_call:{call_id}", mapping={ "caller": caller, "callee": callee, "instance": instance_id, "start_time": str(time.time()), }) r.expire(f"active_call:{call_id}", 7200) # 2-hour TTL # Query active calls across all instances
def get_active_calls(): calls = [] for key in r.scan_iter("active_call:*"): call = r.hgetall(key) calls.append(call) return calls
# Example: Track active calls across all instances
import redis
import json r = redis.Redis(host='redis', port=6379, password='...') # When a call starts on any instance
def on_call_start(call_id, caller, callee, instance_id): r.hset(f"active_call:{call_id}", mapping={ "caller": caller, "callee": callee, "instance": instance_id, "start_time": str(time.time()), }) r.expire(f"active_call:{call_id}", 7200) # 2-hour TTL # Query active calls across all instances
def get_active_calls(): calls = [] for key in r.scan_iter("active_call:*"): call = r.hgetall(key) calls.append(call) return calls
# On the NFS server
apt-get install nfs-kernel-server
mkdir -p /exports/asterisk-recordings
echo '/exports/asterisk-recordings *(rw,sync,no_subtree_check,no_root_squash)' >> /etc/exports
exportfs -ra
# On the NFS server
apt-get install nfs-kernel-server
mkdir -p /exports/asterisk-recordings
echo '/exports/asterisk-recordings *(rw,sync,no_subtree_check,no_root_squash)' >> /etc/exports
exportfs -ra
# On the NFS server
apt-get install nfs-kernel-server
mkdir -p /exports/asterisk-recordings
echo '/exports/asterisk-recordings *(rw,sync,no_subtree_check,no_root_squash)' >> /etc/exports
exportfs -ra
#!/bin/bash
# upload-recording.sh - Called by Asterisk MixMonitor as post-recording command
# MixMonitor(/var/spool/asterisk/monitor/${UNIQUEID}.wav,b,/usr/local/bin/upload-recording.sh ^{UNIQUEID}) RECORDING_FILE="/var/spool/asterisk/monitor/$1.wav"
S3_BUCKET="s3://your-bucket/recordings" if [ -f "$RECORDING_FILE" ]; then aws s3 cp "$RECORDING_FILE" "$S3_BUCKET/$(date +%Y/%m/%d)/$1.wav" \ --storage-class STANDARD_IA # Optionally delete local copy after upload # rm "$RECORDING_FILE"
fi
#!/bin/bash
# upload-recording.sh - Called by Asterisk MixMonitor as post-recording command
# MixMonitor(/var/spool/asterisk/monitor/${UNIQUEID}.wav,b,/usr/local/bin/upload-recording.sh ^{UNIQUEID}) RECORDING_FILE="/var/spool/asterisk/monitor/$1.wav"
S3_BUCKET="s3://your-bucket/recordings" if [ -f "$RECORDING_FILE" ]; then aws s3 cp "$RECORDING_FILE" "$S3_BUCKET/$(date +%Y/%m/%d)/$1.wav" \ --storage-class STANDARD_IA # Optionally delete local copy after upload # rm "$RECORDING_FILE"
fi
#!/bin/bash
# upload-recording.sh - Called by Asterisk MixMonitor as post-recording command
# MixMonitor(/var/spool/asterisk/monitor/${UNIQUEID}.wav,b,/usr/local/bin/upload-recording.sh ^{UNIQUEID}) RECORDING_FILE="/var/spool/asterisk/monitor/$1.wav"
S3_BUCKET="s3://your-bucket/recordings" if [ -f "$RECORDING_FILE" ]; then aws s3 cp "$RECORDING_FILE" "$S3_BUCKET/$(date +%Y/%m/%d)/$1.wav" \ --storage-class STANDARD_IA # Optionally delete local copy after upload # rm "$RECORDING_FILE"
fi
# 1. Build the new image
docker build -t asterisk:21-v2 ./asterisk/ # 2. Start new container alongside the old one
docker compose up -d --no-deps --scale asterisk=2 asterisk # 3. Verify the new container is healthy
docker compose ps # 4. Drain calls from the old container
# (stop sending new calls to it via Kamailio dispatcher)
# Wait for active calls to finish naturally # 5. Remove the old container
docker compose up -d --no-deps --scale asterisk=1 asterisk # 6. Or use rolling updates (Docker Compose does not natively
# support this, but Docker Swarm/Kubernetes do)
# 1. Build the new image
docker build -t asterisk:21-v2 ./asterisk/ # 2. Start new container alongside the old one
docker compose up -d --no-deps --scale asterisk=2 asterisk # 3. Verify the new container is healthy
docker compose ps # 4. Drain calls from the old container
# (stop sending new calls to it via Kamailio dispatcher)
# Wait for active calls to finish naturally # 5. Remove the old container
docker compose up -d --no-deps --scale asterisk=1 asterisk # 6. Or use rolling updates (Docker Compose does not natively
# support this, but Docker Swarm/Kubernetes do)
# 1. Build the new image
docker build -t asterisk:21-v2 ./asterisk/ # 2. Start new container alongside the old one
docker compose up -d --no-deps --scale asterisk=2 asterisk # 3. Verify the new container is healthy
docker compose ps # 4. Drain calls from the old container
# (stop sending new calls to it via Kamailio dispatcher)
# Wait for active calls to finish naturally # 5. Remove the old container
docker compose up -d --no-deps --scale asterisk=1 asterisk # 6. Or use rolling updates (Docker Compose does not natively
# support this, but Docker Swarm/Kubernetes do)
SECURITY
[ ] All default passwords changed in .env
[ ] .env file permissions: chmod 600 .env
[ ] AMI port (5038) NOT exposed externally
[ ] ARI port (8088) NOT exposed externally (proxied through Nginx)
[ ] MariaDB port (3306) NOT exposed externally
[ ] Redis requires password
[ ] TLS certificates installed and valid
[ ] fail2ban configured on the Docker host (not in containers)
[ ] Docker daemon not exposed over TCP
[ ] No secrets in Dockerfile or docker-compose.yml (use .env)
[ ] Container runs as non-root (asterisk user inside container) NETWORKING
[ ] RTP port range mapped correctly (matches rtp.conf)
[ ] external_media_address set to public IP in PJSIP
[ ] external_signaling_address set to public IP in PJSIP
[ ] local_net includes Docker bridge subnet
[ ] Firewall allows SIP (5060-5061), RTP (10000-20000), HTTPS (443)
[ ] NAT tested: make a call from external phone, verify two-way audio
[ ] DNS A record points to server for TLS/WebSocket STORAGE
[ ] Recording directory has adequate disk space
[ ] Recording cleanup cron job installed
[ ] Log rotation configured
[ ] Database backup cron job installed
[ ] Backup restoration tested at least once
[ ] Volumes use bind mounts for critical data (not anonymous volumes) PERFORMANCE
[ ] Resource limits set in docker-compose.yml (CPU, memory)
[ ] ulimits set (nofile: 65536)
[ ] MariaDB innodb_buffer_pool_size tuned for available RAM
[ ] Redis maxmemory set with eviction policy
[ ] Docker logging driver limits set (max-size, max-file) MONITORING
[ ] Health checks configured for all containers
[ ] Prometheus exporter running and scraping
[ ] Log shipping to central logging (Loki/ELK)
[ ] Alerting configured for: container down, disk full, no audio, trunk down
[ ] Dashboard accessible in Grafana OPERATIONAL
[ ] docker-compose.yml version-controlled (Git)
[ ] .env file NOT in version control (in .gitignore)
[ ] Documented procedure for: restart, upgrade, rollback, restore
[ ] Team knows how to access Asterisk CLI: docker compose exec asterisk asterisk -rvvv
[ ] Test call verified: internal, inbound, outbound, voicemail, conference
SECURITY
[ ] All default passwords changed in .env
[ ] .env file permissions: chmod 600 .env
[ ] AMI port (5038) NOT exposed externally
[ ] ARI port (8088) NOT exposed externally (proxied through Nginx)
[ ] MariaDB port (3306) NOT exposed externally
[ ] Redis requires password
[ ] TLS certificates installed and valid
[ ] fail2ban configured on the Docker host (not in containers)
[ ] Docker daemon not exposed over TCP
[ ] No secrets in Dockerfile or docker-compose.yml (use .env)
[ ] Container runs as non-root (asterisk user inside container) NETWORKING
[ ] RTP port range mapped correctly (matches rtp.conf)
[ ] external_media_address set to public IP in PJSIP
[ ] external_signaling_address set to public IP in PJSIP
[ ] local_net includes Docker bridge subnet
[ ] Firewall allows SIP (5060-5061), RTP (10000-20000), HTTPS (443)
[ ] NAT tested: make a call from external phone, verify two-way audio
[ ] DNS A record points to server for TLS/WebSocket STORAGE
[ ] Recording directory has adequate disk space
[ ] Recording cleanup cron job installed
[ ] Log rotation configured
[ ] Database backup cron job installed
[ ] Backup restoration tested at least once
[ ] Volumes use bind mounts for critical data (not anonymous volumes) PERFORMANCE
[ ] Resource limits set in docker-compose.yml (CPU, memory)
[ ] ulimits set (nofile: 65536)
[ ] MariaDB innodb_buffer_pool_size tuned for available RAM
[ ] Redis maxmemory set with eviction policy
[ ] Docker logging driver limits set (max-size, max-file) MONITORING
[ ] Health checks configured for all containers
[ ] Prometheus exporter running and scraping
[ ] Log shipping to central logging (Loki/ELK)
[ ] Alerting configured for: container down, disk full, no audio, trunk down
[ ] Dashboard accessible in Grafana OPERATIONAL
[ ] docker-compose.yml version-controlled (Git)
[ ] .env file NOT in version control (in .gitignore)
[ ] Documented procedure for: restart, upgrade, rollback, restore
[ ] Team knows how to access Asterisk CLI: docker compose exec asterisk asterisk -rvvv
[ ] Test call verified: internal, inbound, outbound, voicemail, conference
SECURITY
[ ] All default passwords changed in .env
[ ] .env file permissions: chmod 600 .env
[ ] AMI port (5038) NOT exposed externally
[ ] ARI port (8088) NOT exposed externally (proxied through Nginx)
[ ] MariaDB port (3306) NOT exposed externally
[ ] Redis requires password
[ ] TLS certificates installed and valid
[ ] fail2ban configured on the Docker host (not in containers)
[ ] Docker daemon not exposed over TCP
[ ] No secrets in Dockerfile or docker-compose.yml (use .env)
[ ] Container runs as non-root (asterisk user inside container) NETWORKING
[ ] RTP port range mapped correctly (matches rtp.conf)
[ ] external_media_address set to public IP in PJSIP
[ ] external_signaling_address set to public IP in PJSIP
[ ] local_net includes Docker bridge subnet
[ ] Firewall allows SIP (5060-5061), RTP (10000-20000), HTTPS (443)
[ ] NAT tested: make a call from external phone, verify two-way audio
[ ] DNS A record points to server for TLS/WebSocket STORAGE
[ ] Recording directory has adequate disk space
[ ] Recording cleanup cron job installed
[ ] Log rotation configured
[ ] Database backup cron job installed
[ ] Backup restoration tested at least once
[ ] Volumes use bind mounts for critical data (not anonymous volumes) PERFORMANCE
[ ] Resource limits set in docker-compose.yml (CPU, memory)
[ ] ulimits set (nofile: 65536)
[ ] MariaDB innodb_buffer_pool_size tuned for available RAM
[ ] Redis maxmemory set with eviction policy
[ ] Docker logging driver limits set (max-size, max-file) MONITORING
[ ] Health checks configured for all containers
[ ] Prometheus exporter running and scraping
[ ] Log shipping to central logging (Loki/ELK)
[ ] Alerting configured for: container down, disk full, no audio, trunk down
[ ] Dashboard accessible in Grafana OPERATIONAL
[ ] docker-compose.yml version-controlled (Git)
[ ] .env file NOT in version control (in .gitignore)
[ ] Documented procedure for: restart, upgrade, rollback, restore
[ ] Team knows how to access Asterisk CLI: docker compose exec asterisk asterisk -rvvv
[ ] Test call verified: internal, inbound, outbound, voicemail, conference
# Add to the asterisk service in docker-compose.yml:
asterisk: # ... existing config ... security_opt: - no-new-privileges:true read_only: true # Read-only root filesystem tmpfs: - /tmp:size=100M - /var/run/asterisk:size=10M cap_drop: - ALL cap_add: - NET_BIND_SERVICE # Bind to ports < 1024 (5060) - SYS_NICE # Set process priority (for real-time audio) - NET_RAW # For ICMP (qualify/keepalive)
# Add to the asterisk service in docker-compose.yml:
asterisk: # ... existing config ... security_opt: - no-new-privileges:true read_only: true # Read-only root filesystem tmpfs: - /tmp:size=100M - /var/run/asterisk:size=10M cap_drop: - ALL cap_add: - NET_BIND_SERVICE # Bind to ports < 1024 (5060) - SYS_NICE # Set process priority (for real-time audio) - NET_RAW # For ICMP (qualify/keepalive)
# Add to the asterisk service in docker-compose.yml:
asterisk: # ... existing config ... security_opt: - no-new-privileges:true read_only: true # Read-only root filesystem tmpfs: - /tmp:size=100M - /var/run/asterisk:size=10M cap_drop: - ALL cap_add: - NET_BIND_SERVICE # Bind to ports < 1024 (5060) - SYS_NICE # Set process priority (for real-time audio) - NET_RAW # For ICMP (qualify/keepalive)
Symptom: Call connects, you can see the call in the CLI, but no audio.
Symptom: Call connects, you can see the call in the CLI, but no audio.
Symptom: Call connects, you can see the call in the CLI, but no audio.
# Check 1: Is external_media_address set correctly?
docker compose exec asterisk asterisk -rx "pjsip show transport transport-udp" | grep external # Check 2: Are RTP ports mapped?
docker compose exec asterisk ss -ulnp | grep 10000 # Check 3: Is RTP traffic reaching the container?
# On the Docker host:
tcpdump -i any -n udp portrange 10000-20000 -c 20 # Check 4: Is direct_media disabled?
docker compose exec asterisk asterisk -rx "pjsip show endpoint 1001" | grep direct_media # Fix: Switch to host networking for quick resolution
# In docker-compose.yml: network_mode: host
# Check 1: Is external_media_address set correctly?
docker compose exec asterisk asterisk -rx "pjsip show transport transport-udp" | grep external # Check 2: Are RTP ports mapped?
docker compose exec asterisk ss -ulnp | grep 10000 # Check 3: Is RTP traffic reaching the container?
# On the Docker host:
tcpdump -i any -n udp portrange 10000-20000 -c 20 # Check 4: Is direct_media disabled?
docker compose exec asterisk asterisk -rx "pjsip show endpoint 1001" | grep direct_media # Fix: Switch to host networking for quick resolution
# In docker-compose.yml: network_mode: host
# Check 1: Is external_media_address set correctly?
docker compose exec asterisk asterisk -rx "pjsip show transport transport-udp" | grep external # Check 2: Are RTP ports mapped?
docker compose exec asterisk ss -ulnp | grep 10000 # Check 3: Is RTP traffic reaching the container?
# On the Docker host:
tcpdump -i any -n udp portrange 10000-20000 -c 20 # Check 4: Is direct_media disabled?
docker compose exec asterisk asterisk -rx "pjsip show endpoint 1001" | grep direct_media # Fix: Switch to host networking for quick resolution
# In docker-compose.yml: network_mode: host
Symptom: SIP phones cannot register, see "401 Unauthorized" or timeout.
Symptom: SIP phones cannot register, see "401 Unauthorized" or timeout.
Symptom: SIP phones cannot register, see "401 Unauthorized" or timeout.
# Check 1: Is PJSIP listening?
docker compose exec asterisk asterisk -rx "pjsip show transports" # Check 2: Enable SIP logging
docker compose exec asterisk asterisk -rx "pjsip set logger on"
# Look for incoming REGISTER and the response # Check 3: Verify credentials
docker compose exec asterisk asterisk -rx "pjsip show endpoint 1001"
docker compose exec asterisk asterisk -rx "pjsip show auth 1001" # Check 4: Is the port reachable from outside?
# From another machine:
nmap -sU -p 5060 YOUR_SERVER_IP # Check 5: Firewall
iptables -L -n | grep 5060
# Check 1: Is PJSIP listening?
docker compose exec asterisk asterisk -rx "pjsip show transports" # Check 2: Enable SIP logging
docker compose exec asterisk asterisk -rx "pjsip set logger on"
# Look for incoming REGISTER and the response # Check 3: Verify credentials
docker compose exec asterisk asterisk -rx "pjsip show endpoint 1001"
docker compose exec asterisk asterisk -rx "pjsip show auth 1001" # Check 4: Is the port reachable from outside?
# From another machine:
nmap -sU -p 5060 YOUR_SERVER_IP # Check 5: Firewall
iptables -L -n | grep 5060
# Check 1: Is PJSIP listening?
docker compose exec asterisk asterisk -rx "pjsip show transports" # Check 2: Enable SIP logging
docker compose exec asterisk asterisk -rx "pjsip set logger on"
# Look for incoming REGISTER and the response # Check 3: Verify credentials
docker compose exec asterisk asterisk -rx "pjsip show endpoint 1001"
docker compose exec asterisk asterisk -rx "pjsip show auth 1001" # Check 4: Is the port reachable from outside?
# From another machine:
nmap -sU -p 5060 YOUR_SERVER_IP # Check 5: Firewall
iptables -L -n | grep 5060
Symptom: Call fails with "488 Not Acceptable Here" or no compatible codecs.
Symptom: Call fails with "488 Not Acceptable Here" or no compatible codecs.
Symptom: Call fails with "488 Not Acceptable Here" or no compatible codecs.
# Check allowed codecs on the endpoint
docker compose exec asterisk asterisk -rx "pjsip show endpoint 1001" | grep -i allow # Check what codecs are actually loaded
docker compose exec asterisk asterisk -rx "core show codecs" # Verify Opus is loaded (if using WebRTC)
docker compose exec asterisk asterisk -rx "module show like opus" # Fix: ensure both sides support at least one common codec
# Most compatible: allow = ulaw,alaw (G.711)
# Best for WebRTC: allow = opus,ulaw
# Check allowed codecs on the endpoint
docker compose exec asterisk asterisk -rx "pjsip show endpoint 1001" | grep -i allow # Check what codecs are actually loaded
docker compose exec asterisk asterisk -rx "core show codecs" # Verify Opus is loaded (if using WebRTC)
docker compose exec asterisk asterisk -rx "module show like opus" # Fix: ensure both sides support at least one common codec
# Most compatible: allow = ulaw,alaw (G.711)
# Best for WebRTC: allow = opus,ulaw
# Check allowed codecs on the endpoint
docker compose exec asterisk asterisk -rx "pjsip show endpoint 1001" | grep -i allow # Check what codecs are actually loaded
docker compose exec asterisk asterisk -rx "core show codecs" # Verify Opus is loaded (if using WebRTC)
docker compose exec asterisk asterisk -rx "module show like opus" # Fix: ensure both sides support at least one common codec
# Most compatible: allow = ulaw,alaw (G.711)
# Best for WebRTC: allow = opus,ulaw
Symptom: Asterisk cannot resolve SIP trunk hostnames, outbound calls fail.
Symptom: Asterisk cannot resolve SIP trunk hostnames, outbound calls fail.
Symptom: Asterisk cannot resolve SIP trunk hostnames, outbound calls fail.
# Check DNS from inside the container
docker compose exec asterisk nslookup sip.provider.example.com # Check Docker DNS configuration
docker compose exec asterisk cat /etc/resolv.conf # Fix: Add explicit DNS servers in docker-compose.yml
asterisk: dns: - 8.8.8.8 - 1.1.1.1
# Check DNS from inside the container
docker compose exec asterisk nslookup sip.provider.example.com # Check Docker DNS configuration
docker compose exec asterisk cat /etc/resolv.conf # Fix: Add explicit DNS servers in docker-compose.yml
asterisk: dns: - 8.8.8.8 - 1.1.1.1
# Check DNS from inside the container
docker compose exec asterisk nslookup sip.provider.example.com # Check Docker DNS configuration
docker compose exec asterisk cat /etc/resolv.conf # Fix: Add explicit DNS servers in docker-compose.yml
asterisk: dns: - 8.8.8.8 - 1.1.1.1
# Check logs for crash reason
docker compose logs --tail=50 asterisk # Check if it's an OOM kill
docker inspect asterisk | grep -A5 "State"
dmesg | grep -i "oom\|killed" # Check resource limits
docker stats asterisk --no-stream # Common causes:
# - Module loading error (missing dependency)
# - Configuration syntax error
# - Port conflict with another container
# - Insufficient memory limit
# Check logs for crash reason
docker compose logs --tail=50 asterisk # Check if it's an OOM kill
docker inspect asterisk | grep -A5 "State"
dmesg | grep -i "oom\|killed" # Check resource limits
docker stats asterisk --no-stream # Common causes:
# - Module loading error (missing dependency)
# - Configuration syntax error
# - Port conflict with another container
# - Insufficient memory limit
# Check logs for crash reason
docker compose logs --tail=50 asterisk # Check if it's an OOM kill
docker inspect asterisk | grep -A5 "State"
dmesg | grep -i "oom\|killed" # Check resource limits
docker stats asterisk --no-stream # Common causes:
# - Module loading error (missing dependency)
# - Configuration syntax error
# - Port conflict with another container
# - Insufficient memory limit
Symptom: docker compose up takes 30+ seconds for the asterisk container.
Symptom: docker compose up takes 30+ seconds for the asterisk container.
Symptom: docker compose up takes 30+ seconds for the asterisk container.
# Docker creates iptables rules for each port in the range.
# 10000 ports = 10000 iptables rules. # Fix 1: Narrow the port range
# rtp.conf: rtpend = 10200
# docker-compose.yml: ports: "10000-10200:10000-10200/udp" # Fix 2: Use host networking (no port mapping needed)
# network_mode: host # Fix 3: Use nftables backend instead of iptables (faster)
# /etc/docker/daemon.json: {"iptables": false}
# Then manage nftables rules manually
# Docker creates iptables rules for each port in the range.
# 10000 ports = 10000 iptables rules. # Fix 1: Narrow the port range
# rtp.conf: rtpend = 10200
# docker-compose.yml: ports: "10000-10200:10000-10200/udp" # Fix 2: Use host networking (no port mapping needed)
# network_mode: host # Fix 3: Use nftables backend instead of iptables (faster)
# /etc/docker/daemon.json: {"iptables": false}
# Then manage nftables rules manually
# Docker creates iptables rules for each port in the range.
# 10000 ports = 10000 iptables rules. # Fix 1: Narrow the port range
# rtp.conf: rtpend = 10200
# docker-compose.yml: ports: "10000-10200:10000-10200/udp" # Fix 2: Use host networking (no port mapping needed)
# network_mode: host # Fix 3: Use nftables backend instead of iptables (faster)
# /etc/docker/daemon.json: {"iptables": false}
# Then manage nftables rules manually
# ---- Container Management ----
docker compose up -d # Start all services
docker compose down # Stop all services
docker compose restart asterisk # Restart Asterisk only
docker compose logs -f asterisk # Follow Asterisk logs
docker compose ps # Show container status
docker compose top # Show running processes # ---- Asterisk CLI ----
docker compose exec asterisk asterisk -rvvv # Interactive CLI
docker compose exec asterisk asterisk -rx "core show channels"
docker compose exec asterisk asterisk -rx "pjsip show endpoints"
docker compose exec asterisk asterisk -rx "pjsip show registrations"
docker compose exec asterisk asterisk -rx "confbridge list"
docker compose exec asterisk asterisk -rx "core show uptime"
docker compose exec asterisk asterisk -rx "module reload" # ---- Database ----
docker compose exec mariadb mysql -u asterisk -p asterisk
docker compose exec mariadb mysql -u asterisk -p asterisk \ -e "SELECT COUNT(*) as total_calls, DATE(calldate) as day FROM cdr GROUP BY day ORDER BY day DESC LIMIT 7;" # ---- Debugging ----
docker compose exec asterisk sngrep # SIP packet capture
docker compose exec asterisk tcpdump -i any -n -w /tmp/capture.pcap port 5060
docker compose exec asterisk asterisk -rx "pjsip set logger on"
docker compose exec asterisk asterisk -rx "rtp set debug on" # ---- Upgrades ----
docker compose build --no-cache asterisk # Rebuild with latest source
docker compose up -d asterisk # Replace running container
docker compose exec asterisk asterisk -V # Verify new version # ---- Backup ----
/opt/asterisk-docker/scripts/backup.sh # Manual backup
ls -lh /opt/asterisk-docker/backups/ # List backups
# ---- Container Management ----
docker compose up -d # Start all services
docker compose down # Stop all services
docker compose restart asterisk # Restart Asterisk only
docker compose logs -f asterisk # Follow Asterisk logs
docker compose ps # Show container status
docker compose top # Show running processes # ---- Asterisk CLI ----
docker compose exec asterisk asterisk -rvvv # Interactive CLI
docker compose exec asterisk asterisk -rx "core show channels"
docker compose exec asterisk asterisk -rx "pjsip show endpoints"
docker compose exec asterisk asterisk -rx "pjsip show registrations"
docker compose exec asterisk asterisk -rx "confbridge list"
docker compose exec asterisk asterisk -rx "core show uptime"
docker compose exec asterisk asterisk -rx "module reload" # ---- Database ----
docker compose exec mariadb mysql -u asterisk -p asterisk
docker compose exec mariadb mysql -u asterisk -p asterisk \ -e "SELECT COUNT(*) as total_calls, DATE(calldate) as day FROM cdr GROUP BY day ORDER BY day DESC LIMIT 7;" # ---- Debugging ----
docker compose exec asterisk sngrep # SIP packet capture
docker compose exec asterisk tcpdump -i any -n -w /tmp/capture.pcap port 5060
docker compose exec asterisk asterisk -rx "pjsip set logger on"
docker compose exec asterisk asterisk -rx "rtp set debug on" # ---- Upgrades ----
docker compose build --no-cache asterisk # Rebuild with latest source
docker compose up -d asterisk # Replace running container
docker compose exec asterisk asterisk -V # Verify new version # ---- Backup ----
/opt/asterisk-docker/scripts/backup.sh # Manual backup
ls -lh /opt/asterisk-docker/backups/ # List backups
# ---- Container Management ----
docker compose up -d # Start all services
docker compose down # Stop all services
docker compose restart asterisk # Restart Asterisk only
docker compose logs -f asterisk # Follow Asterisk logs
docker compose ps # Show container status
docker compose top # Show running processes # ---- Asterisk CLI ----
docker compose exec asterisk asterisk -rvvv # Interactive CLI
docker compose exec asterisk asterisk -rx "core show channels"
docker compose exec asterisk asterisk -rx "pjsip show endpoints"
docker compose exec asterisk asterisk -rx "pjsip show registrations"
docker compose exec asterisk asterisk -rx "confbridge list"
docker compose exec asterisk asterisk -rx "core show uptime"
docker compose exec asterisk asterisk -rx "module reload" # ---- Database ----
docker compose exec mariadb mysql -u asterisk -p asterisk
docker compose exec mariadb mysql -u asterisk -p asterisk \ -e "SELECT COUNT(*) as total_calls, DATE(calldate) as day FROM cdr GROUP BY day ORDER BY day DESC LIMIT 7;" # ---- Debugging ----
docker compose exec asterisk sngrep # SIP packet capture
docker compose exec asterisk tcpdump -i any -n -w /tmp/capture.pcap port 5060
docker compose exec asterisk asterisk -rx "pjsip set logger on"
docker compose exec asterisk asterisk -rx "rtp set debug on" # ---- Upgrades ----
docker compose build --no-cache asterisk # Rebuild with latest source
docker compose up -d asterisk # Replace running container
docker compose exec asterisk asterisk -V # Verify new version # ---- Backup ----
/opt/asterisk-docker/scripts/backup.sh # Manual backup
ls -lh /opt/asterisk-docker/backups/ # List backups - Introduction
- Architecture Overview
- Prerequisites
- Dockerfile Deep Dive
- Docker Compose Stack
- Asterisk Configuration Templates
- RTP & NAT Challenges
- Persistent Storage Strategy
- Database Integration
- ARI (Asterisk REST Interface)
- WebRTC Support
- Monitoring & Logging
- Scaling & High Availability
- Production Checklist & Troubleshooting - Snowflake servers: Every Asterisk box drifts from the others over time. Different module versions, different patches, different configs. When one breaks, you cannot reproduce the issue anywhere else.
- Upgrades are terrifying: Upgrading Asterisk on a production server means recompiling on the live box, hoping dependencies do not break, and praying the new version does not regress your codec or SRTP support.
- Environment dependencies: Asterisk links against specific versions of libpjproject, libsrtp, libopus, and dozens of other libraries. A system update can silently break things.
- No rollback path: If an upgrade goes wrong, you are restoring from backup and hoping your config files match the binary. - ViciDial: ViciDial is deeply coupled to its host OS. It expects specific paths, cron jobs, screen sessions, Apache with mod_php, and direct filesystem access for recordings. Containerizing ViciDial is a multi-month project that most teams should avoid. Use VMs.
- DAHDI timing: If you need MeetMe conferencing (not ConfBridge), you need DAHDI kernel modules for timing. DAHDI requires kernel module compilation on the host, which defeats much of the container isolation benefit. ConfBridge uses res_timing_timerfd instead and works perfectly in containers.
- Ultra-low latency requirements: Docker's bridge networking adds ~50-100 microseconds of latency per packet. For most VoIP this is negligible, but if you are doing carrier-grade media processing at scale, test carefully.
- Existing stable deployments: If you have a bare-metal Asterisk that has been running for years and you are the only one who touches it, containerization adds complexity without clear benefit. Do not fix what is not broken. - Module selection: Include only what you need (PJSIP, ARI, ConfBridge, Opus) and exclude what you do not (chan_sip, chan_dahdi, res_phoneprov)
- Codec support: Compile with Opus, which is not included in most distribution packages
- Reproducible builds: The exact same binary every time, regardless of when you build
- Security: Smaller image = smaller attack surface. No compiler, no headers, no build tools in production