Tools: Breaking: Containerized Asterisk — Production Docker Compose Stack

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 abuse

Code Block

Copy

┌─────────────────────────────────────────────────────────────────────┐ │ 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">&nbsp;</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">&nbsp;</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">&nbsp;</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