Saltearse al contenido

Taller 8: Auditoría de Llamadas con CDR/CEL y PostgreSQL

Sistema profesional de auditoría: módulos nativos PostgreSQL, grabaciones con MixMonitor y reportes SQL completos

📊 Taller 8: Auditoría de Llamadas con CDR/CEL y PostgreSQL (Módulos Nativos)

Sección titulada «📊 Taller 8: Auditoría de Llamadas con CDR/CEL y PostgreSQL (Módulos Nativos)»

🗂️ El Problema Real: Auditoría Sin Compromiso de Rendimiento

Sección titulada «🗂️ El Problema Real: Auditoría Sin Compromiso de Rendimiento»

Tu PBX funciona perfectamente: llamadas internas, troncal SIP, CallerID dinámico y notificaciones por WhatsApp. Pero cuando necesitas responder preguntas críticas de negocio como:

  • ¿Cuántas llamadas procesamos por hora en pico de tráfico?
  • ¿Cuál es el patrón de comportamiento de cada cliente específico?
  • ¿Puedo correlacionar una grabación con todos los eventos de la llamada?
  • ¿Necesito que el sistema responda en tiempo real sin latencias?

…te das cuenta de que necesitas una solución de alto rendimiento que no agregue capas innecesarias entre Asterisk y la base de datos.

AspectoNativo (cdr_pgsql)ODBC (enfoque alternativo)
Rendimiento⚡ Conexión directa a libpq🐌 Capa intermedia ODBC
Latencia🎯 Mínima⚠️ Adicional por traducción
Dependencias📦 Solo libpq-dev🔧 UnixODBC + odbc-postgresql
Configuración✅ Archivos simples❌ DSN + múltiples capas
Puntos de falla🎯 Uno (PostgreSQL)⚠️ Múltiples (ODBC + PostgreSQL)
Mantenimiento🏆 Soporte oficial Asterisk🔧 Compatibilidad genérica
Debugging🔍 Logs directos PostgreSQL🤯 Logs ODBC + PostgreSQL

⚠️ Importante: ¿Por qué Necesitamos Recompilar Asterisk?

Sección titulada «⚠️ Importante: ¿Por qué Necesitamos Recompilar Asterisk?»

En el Taller 2, compilamos Asterisk sin la biblioteca de desarrollo de PostgreSQL (libpq-dev). Esto significa que:

  1. libpq-dev no estaba disponible durante la compilación
  2. Los módulos cdr_pgsql.so y cel_pgsql.so NO se compilaron
  3. En make menuselect aparecerían marcados con XXX (no disponibles)

Términos clave:

  • libpq: Biblioteca cliente oficial de PostgreSQL escrita en C
  • libpq-dev: Archivos de cabecera (headers) necesarios para compilar programas que usen libpq
  • libpq-fe.h: Archivo de cabecera principal que incluye Asterisk para comunicarse con PostgreSQL

8.0 Buenas Prácticas: Backup y Seguridad Primero

Sección titulada «8.0 Buenas Prácticas: Backup y Seguridad Primero»

Como siempre, protegemos nuestro trabajo antes de realizar cambios importantes.

Terminal window
# Backup de configuración de Asterisk
cd /etc/asterisk
cp -rfv . ../asterisk.bk-$(date +%F-%H%M)
# Backup del código fuente actual (si existe)
cp -rfv /usr/src/asterisk-22.* /usr/src/asterisk-22-backup-$(date +%F-%H%M) 2>/dev/null || echo "No hay código fuente previo"
# Verificar espacio disponible para grabaciones
df -h /var/spool/asterisk/

8.0.2 Verificar Estado Actual de Módulos PostgreSQL

Sección titulada «8.0.2 Verificar Estado Actual de Módulos PostgreSQL»
Terminal window
# Conectar al CLI y verificar módulos disponibles
asterisk -rx "module show like pgsql"

Si ves algo como:

Module Description
0 modules loaded

Confirma que necesitamos recompilar. Si ves los módulos cargados, puedes saltar a la sección 8.2.


8.1 Preparación: Instalación de libpq-dev y Recompilación

Sección titulada «8.1 Preparación: Instalación de libpq-dev y Recompilación»

8.1.1 Instalación de PostgreSQL y Dependencias de Desarrollo

Sección titulada «8.1.1 Instalación de PostgreSQL y Dependencias de Desarrollo»
Terminal window
# Actualizar repositorios
apt update
# Instalar PostgreSQL completo y herramientas de desarrollo
apt install -y postgresql postgresql-client postgresql-contrib
# CRÍTICO: Instalar libpq-dev ANTES de recompilar Asterisk
apt install -y libpq-dev
# Verificar que libpq-dev esté correctamente instalado
pkg-config --exists libpq && echo "libpq-dev OK" || echo "libpq-dev ERROR"
# Verificar ubicación de headers
ls -la /usr/include/postgresql/libpq-fe.h
# Verificar bibliotecas disponibles
ldconfig -p | grep pq

8.1.2 Recompilación de Asterisk con Soporte PostgreSQL

Sección titulada «8.1.2 Recompilación de Asterisk con Soporte PostgreSQL»
Terminal window
# Ir al directorio del código fuente
cd /usr/src/asterisk-22.*
# Limpiar compilación anterior
make clean
# Reconfigurar con detección de PostgreSQL
./configure --with-jansson-bundled
# Verificar que PostgreSQL fue detectado
grep -i postgres config.log

Deberías ver líneas como:

configure: checking for PQescapeStringConn in -lpq... yes

8.1.3 Verificar Disponibilidad en menuselect

Sección titulada «8.1.3 Verificar Disponibilidad en menuselect»
Terminal window
# Abrir menuselect
make menuselect

Navega a “Call Detail Recording” y “Channel Event Logging”:

  • cdr_pgsql debe aparecer SIN XXX (disponible para selección)
  • cel_pgsql debe aparecer SIN XXX (disponible para selección)

Selecciona en menuselect:

  • Call Detail Recordingcdr_pgsql
  • Channel Event Loggingcel_pgsql

Guarda (F12) y sal.

Terminal window
# Compilar (esto tomará algunos minutos)
make
# Instalar módulos actualizados
make install
# NO ejecutar make samples (sobreescribiría nuestras configuraciones)
# Verificar que los módulos se instalaron
ls -la /usr/lib/asterisk/modules/*pgsql*

Deberías ver:

-rwxr-xr-x 1 root root cdr_pgsql.so
-rwxr-xr-x 1 root root cel_pgsql.so

8.2 Configuración de PostgreSQL para Asterisk

Sección titulada «8.2 Configuración de PostgreSQL para Asterisk»
Terminal window
# Verificar que PostgreSQL esté corriendo
systemctl status postgresql
# Instalar sudo si no está instalado
apt-get install sudo -y
# Cambiar a usuario postgres para configuración inicial
sudo -u postgres psql

Dentro del prompt de PostgreSQL (postgres=#):

-- Crear usuario específico para Asterisk con contraseña segura 2025
CREATE ROLE asterisk WITH LOGIN PASSWORD 'AsteriskAudit2025!';
-- Crear base de datos dedicada
CREATE DATABASE aulaswitch OWNER asterisk;
-- Otorgar permisos completos al usuario asterisk sobre la base de datos
GRANT ALL PRIVILEGES ON DATABASE aulaswitch TO asterisk;
-- Crear usuario de solo lectura para reportes
CREATE ROLE asterisk_reports WITH LOGIN PASSWORD 'Reports2025!';
GRANT CONNECT ON DATABASE aulaswitch TO asterisk_reports;
-- Verificar la creación
\l
-- Salir
\q

8.2.2 Configuración de Autenticación PostgreSQL

Sección titulada «8.2.2 Configuración de Autenticación PostgreSQL»
Terminal window
# Editar archivo de autenticación PostgreSQL
nano /etc/postgresql/*/main/pg_hba.conf

Busca la sección # "local" is for Unix domain socket connections y añade ANTES de la línea local all all peer:

# TYPE DATABASE USER ADDRESS METHOD
# Configuración específica para Asterisk
local aulaswitch asterisk md5
local aulaswitch asterisk_reports md5
# Configuración por defecto (mantener las líneas existentes)
local all postgres peer
local all all peer
host all all 127.0.0.1/32 scram-sha-256
host all all ::1/128 scram-sha-256
Terminal window
# Reiniciar PostgreSQL para aplicar cambios
systemctl restart postgresql
Terminal window
# Probar conexión como usuario asterisk
psql -U asterisk -d aulaswitch -h localhost

Te pedirá la contraseña (AsteriskAudit2025!). Si conecta exitosamente:

-- Verificar versión y configuración
SELECT version();
SHOW timezone;
\q

8.3 Diseño y Creación de Esquema de Base de Datos

Sección titulada «8.3 Diseño y Creación de Esquema de Base de Datos»
Terminal window
psql -U asterisk -d aulaswitch -h localhost
-- Tabla CDR: Un registro por llamada completada
-- Esquema basado en la documentación oficial de Asterisk
CREATE TABLE public.cdr (
-- Información temporal de la llamada
calldate TIMESTAMP WITH TIME ZONE DEFAULT NOW(), -- Momento exacto del inicio de llamada
clid VARCHAR(80) DEFAULT '', -- Caller ID completo "Nombre <numero>"
src VARCHAR(80) DEFAULT '', -- Número origen (quien llama)
dst VARCHAR(80) DEFAULT '', -- Número destino (a quien llama)
dcontext VARCHAR(80) DEFAULT '', -- Contexto de dialplan del destino
-- Información de canales
channel VARCHAR(80) DEFAULT '', -- Canal origen (ej: PJSIP/1001-00000001)
dstchannel VARCHAR(80) DEFAULT '', -- Canal destino (ej: PJSIP/trunk-00000002)
-- Información de aplicaciones
lastapp VARCHAR(80) DEFAULT '', -- Última aplicación ejecutada (Dial, Hangup, etc.)
lastdata VARCHAR(512) DEFAULT '', -- Parámetros de la última aplicación
-- Métricas de tiempo (en segundos)
duration INTEGER DEFAULT 0, -- Duración total: desde Answer hasta Hangup
billsec INTEGER DEFAULT 0, -- Duración facturable: desde que contesta hasta que cuelga
-- Estado y clasificación
disposition VARCHAR(45) DEFAULT '', -- ANSWERED, NO ANSWER, BUSY, FAILED, CONGESTION
amaflags INTEGER DEFAULT 0, -- Automatic Message Accounting: 0=Default, 1=Omit, 2=Billing, 3=Documentation
accountcode VARCHAR(20) DEFAULT '', -- Código de cuenta para facturación
-- Identificadores únicos
uniqueid VARCHAR(32) DEFAULT '', -- ID único de la llamada (correlación con CEL)
linkedid VARCHAR(32) DEFAULT '', -- ID que agrupa llamadas relacionadas (transferencias)
peeraccount VARCHAR(20) DEFAULT '', -- Cuenta del peer remoto
sequence INTEGER DEFAULT 0, -- Número de secuencia para llamadas múltiples
-- Campos personalizados para auditoría avanzada
userfield VARCHAR(255) DEFAULT '', -- Campo libre para datos personalizados
recording_path TEXT DEFAULT NULL -- Ruta completa al archivo de grabación
);
-- Crear índices para optimizar consultas frecuentes
CREATE INDEX cdr_calldate_idx ON cdr(calldate); -- Búsquedas por fecha
CREATE INDEX cdr_uniqueid_idx ON cdr(uniqueid); -- Correlación con CEL
CREATE INDEX cdr_src_idx ON cdr(src); -- Búsquedas por origen
CREATE INDEX cdr_dst_idx ON cdr(dst); -- Búsquedas por destino
CREATE INDEX cdr_disposition_idx ON cdr(disposition); -- Filtros por resultado
CREATE INDEX cdr_accountcode_idx ON cdr(accountcode); -- Reportes por tipo de llamada
CREATE INDEX cdr_linkedid_idx ON cdr(linkedid); -- Llamadas relacionadas

8.3.2 Crear Esquema CEL (Channel Event Logging)

Sección titulada «8.3.2 Crear Esquema CEL (Channel Event Logging)»
-- Tabla CEL: Múltiples registros por llamada (eventos detallados)
-- Cada fila representa un evento específico en el ciclo de vida de la llamada
CREATE TABLE public.cel (
-- Identificador único del registro
id SERIAL PRIMARY KEY, -- ID autoincremental único
-- Información temporal
eventtime TIMESTAMP WITH TIME ZONE DEFAULT NOW(), -- Momento exacto del evento
-- Clasificación del evento
eventtype VARCHAR(30) DEFAULT '', -- Tipo: CHAN_START, ANSWER, HANGUP, BRIDGE_ENTER, etc.
userdeftype VARCHAR(255) DEFAULT '', -- Eventos personalizados definidos por usuario
-- Información del Caller ID
cid_name VARCHAR(80) DEFAULT '', -- Nombre del Caller ID
cid_num VARCHAR(80) DEFAULT '', -- Número del Caller ID
cid_ani VARCHAR(80) DEFAULT '', -- ANI (Automatic Number Identification)
cid_rdnis VARCHAR(80) DEFAULT '', -- RDNIS (Redirecting Directory Number Information Service)
cid_dnid VARCHAR(80) DEFAULT '', -- DNID (Dialed Number Identification Service)
-- Información de contexto y aplicación
exten VARCHAR(80) DEFAULT '', -- Extensión/número marcado
context VARCHAR(80) DEFAULT '', -- Contexto de dialplan
channame VARCHAR(80) DEFAULT '', -- Nombre técnico del canal
appname VARCHAR(80) DEFAULT '', -- Aplicación ejecutándose (Dial, Queue, etc.)
appdata VARCHAR(512) DEFAULT '', -- Parámetros de la aplicación
-- Información de facturación y cuentas
amaflags INTEGER DEFAULT 0, -- Flags de AMA
accountcode VARCHAR(20) DEFAULT '', -- Código de cuenta
peeraccount VARCHAR(20) DEFAULT '', -- Cuenta del peer
-- Identificadores de correlación
uniqueid VARCHAR(32) DEFAULT '', -- ID único del canal
linkedid VARCHAR(32) DEFAULT '', -- ID que agrupa eventos de la misma "llamada lógica"
-- Campos adicionales
userfield VARCHAR(255) DEFAULT '', -- Campo personalizable
peer VARCHAR(80) DEFAULT '' -- Información del peer remoto
);
-- Índices para optimizar consultas CEL
CREATE INDEX cel_eventtime_idx ON cel(eventtime); -- Búsquedas por tiempo
CREATE INDEX cel_uniqueid_idx ON cel(uniqueid); -- Eventos de un canal específico
CREATE INDEX cel_linkedid_idx ON cel(linkedid); -- Eventos de una llamada lógica
CREATE INDEX cel_eventtype_idx ON cel(eventtype); -- Filtros por tipo de evento
CREATE INDEX cel_linkedid_eventtime_idx ON cel(linkedid, eventtime); -- Flujo cronológico de llamada

8.3.3 Otorgar Permisos y Crear Vistas Útiles

Sección titulada «8.3.3 Otorgar Permisos y Crear Vistas Útiles»
-- Otorgar permisos al usuario de reportes
GRANT USAGE ON SCHEMA public TO asterisk_reports;
GRANT SELECT ON cdr, cel TO asterisk_reports;
GRANT USAGE, SELECT ON SEQUENCE cel_id_seq TO asterisk_reports;
-- Vista que correlaciona CDR con información de CEL
CREATE VIEW v_llamadas_completas AS
SELECT
cdr.calldate,
cdr.src,
cdr.dst,
cdr.duration,
cdr.billsec,
cdr.disposition,
cdr.accountcode,
cdr.recording_path,
cdr.uniqueid,
cdr.linkedid,
-- Información derivada de CEL
MIN(CASE WHEN cel.eventtype = 'ANSWER' THEN cel.eventtime END) as momento_respuesta,
MAX(CASE WHEN cel.eventtype = 'HANGUP' THEN cel.eventtime END) as momento_colgado,
COUNT(cel.id) as total_eventos_cel
FROM cdr
LEFT JOIN cel ON cdr.uniqueid = cel.uniqueid
GROUP BY cdr.uniqueid, cdr.calldate, cdr.src, cdr.dst, cdr.duration,
cdr.billsec, cdr.disposition, cdr.accountcode, cdr.recording_path, cdr.linkedid;
-- Otorgar acceso a la vista
GRANT SELECT ON v_llamadas_completas TO asterisk_reports;
-- Verificar creación de tablas
\dt
\d cdr
\d cel
-- Salir
\q

8.4 Configuración de Asterisk: Módulos Nativos PostgreSQL

Sección titulada «8.4 Configuración de Asterisk: Módulos Nativos PostgreSQL»
Terminal window
nano /etc/asterisk/cdr.conf
;
; Configuración para CDR con PostgreSQL nativo
; Este archivo configura el módulo cdr_pgsql.so para escribir
; directamente a PostgreSQL sin capas intermedias como ODBC
;
[general]
; Habilitar el módulo CDR
enable=yes
Terminal window
nano /etc/asterisk/cdr_pgsql.conf
;
; Configuración para CDR con PostgreSQL nativo
; Este archivo configura el módulo cdr_pgsql.so para escribir
; directamente a PostgreSQL sin capas intermedias como ODBC
;
[global]
; === CONFIGURACIÓN DE CONEXIÓN ===
; hostname: Dirección del servidor PostgreSQL
; - 'localhost' usa socket Unix (más rápido para servidor local)
; - IP específica fuerza conexión TCP/IP
hostname=localhost
; port: Puerto de PostgreSQL (5432 es el estándar)
port=5432
; dbname: Nombre de la base de datos donde se almacenarán los CDR
dbname=aulaswitch
; user: Usuario PostgreSQL con permisos de INSERT en tabla CDR
user=asterisk
; password: Contraseña del usuario (usar contraseñas fuertes en producción)
password=AsteriskAudit2025!
; appname: Nombre que aparecerá en logs de PostgreSQL para identificar conexiones de Asterisk
; Útil para monitoreo y debugging
appname=asterisk-cdr
; table: Nombre de la tabla donde se insertarán los registros CDR
table=cdr
; === CONFIGURACIÓN DE FORMATO Y TIMEZONE ===
; encoding: Codificación de caracteres para datos almacenados
; UTF8 es recomendado para soporte internacional
encoding=UTF8
; timezone: Zona horaria para timestamps
; Comentado = usa timezone del sistema
; UTC recomendado para ambientes distribuidos
;timezone=UTC
; === CONFIGURACIÓN DE COMPORTAMIENTO ===
; Estas opciones controlan cómo se comporta el módulo CDR
; Si usegmtime=yes, todos los timestamps se almacenan en GMT/UTC
; Si usegmtime=no, usa la zona horaria local del sistema
; Recomendado: no (para consistencia con otros logs de Asterisk)
;usegmtime=no
; Si la conexión a PostgreSQL falla, ¿debe Asterisk seguir funcionando?
; yes = continúa sin grabar CDR (llamadas no se interrumpen)
; no = puede afectar las llamadas si la BD está caída
;failover=yes
; Configuración de pooling de conexiones (para alto volumen)
; max_connections: Máximo de conexiones simultáneas a PostgreSQL
;max_connections=5
; connection_timeout: Segundos antes de considerar la conexión fallida
;connection_timeout=10
Terminal window
nano /etc/asterisk/cel.conf
[general]
; CEL Activation
;
; Use the 'enable' keyword to turn CEL on or off.
;
; Accepted values: yes and no
; Default value: no
enable=yes
Terminal window
nano /etc/asterisk/cel_pgsql.conf
;
; Configuración para CEL (Channel Event Logging) con PostgreSQL nativo
; CEL registra eventos detallados del ciclo de vida de cada canal
; Útil para análisis forense, debugging y correlación con CDR
;
[global]
; === CONFIGURACIÓN DE CONEXIÓN ===
; Misma configuración que CDR para consistencia
hostname=localhost
port=5432
dbname=aulaswitch
user=asterisk
password=AsteriskAudit2025!
appname=asterisk-cel
; table: Tabla donde se almacenan los eventos CEL
table=cel
; === CONFIGURACIÓN DE EVENTOS ===
; show_user_defined: Registrar eventos personalizados definidos por dialplan
; Útil si usas UserEvent() en tu dialplan
show_user_defined=yes
; === CONFIGURACIÓN DE TIMEZONE ===
; usegmtime: Similar a CDR, controla si usar GMT o tiempo local
usegmtime=no
; === CONFIGURACIÓN AVANZADA ===
; schema: Esquema de PostgreSQL donde está la tabla CEL
; Por defecto usa 'public', pero puedes usar esquemas separados
; para organizar mejor la base de datos
;schema=public
; === FILTROS DE EVENTOS (OPCIONAL) ===
; Puedes filtrar qué tipos de eventos CEL se registran
; Comentado = registra todos los eventos
; Ejemplos de filtros:
;eventfilter=CHAN_START,ANSWER,HANGUP,BRIDGE_ENTER,BRIDGE_EXIT
; === CONFIGURACIÓN DE RENDIMIENTO ===
; Para sistemas de alto volumen, considera:
; - Usar una conexión dedicada para CEL
; - Configurar buffer de escritura
; - Usar particionado de tablas por fecha
;batch_size=100
;flush_interval=5
Terminal window
nano /etc/asterisk/modules.conf

Busca la sección de módulos y asegúrate de que esté presente:

; === MÓDULOS CDR (Call Detail Records) ===
; Cargar módulo nativo PostgreSQL para CDR
load = cdr_pgsql.so
; === MÓDULOS CEL (Channel Event Logging) ===
; Cargar módulo nativo PostgreSQL para CEL
load = cel_pgsql.so
; === MÓDULOS NO REQUERIDOS ===
; No cargar módulos ODBC si usamos nativos
noload = cdr_odbc.so
noload = cel_odbc.so
; No cargar módulos MySQL obsoletos
noload = cdr_mysql.so
noload = cel_mysql.so
; === MÓDULOS DE SOPORTE ===
; res_pgsql: Módulo base para funcionalidad PostgreSQL en Asterisk
; (Se carga automáticamente como dependencia)
;load = res_pgsql.so

8.5 Integración de MixMonitor en el Dialplan

Sección titulada «8.5 Integración de MixMonitor en el Dialplan»

Ahora modificamos nuestro dialplan para grabar llamadas automáticamente y almacenar la ruta del archivo en CDR.

Terminal window
# Crear directorio con estructura organizada por fecha
mkdir -p /var/spool/asterisk/monitor/
mkdir -p /var/spool/asterisk/monitor/{inbound,outbound,internal}
# Asignar permisos correctos
# Si se instala asterisk como usuario asterisk, si fue con root no se requiere
# chown -R asterisk:asterisk /var/spool/asterisk/monitor/
chmod -R 755 /var/spool/asterisk/monitor/
# Verificar permisos
ls -la /var/spool/asterisk/monitor/

8.5.2 Función de Utilidad para Nombres de Grabación

Sección titulada «8.5.2 Función de Utilidad para Nombres de Grabación»
Terminal window
nano /etc/asterisk/extensions_funciones.conf

Agregar al final del archivo:

; === FUNCIONES DE GRABACIÓN ===
; Subrutina para generar nombres consistentes de grabaciones
; Formato: YYYYMMDD-HHMMSS-tipo-origen-destino.wav
[generar-nombre-grabacion]
; ARG1 = tipo de llamada (ENTRANTE, SALIENTE, INTERNO)
; ARG2 = número origen
; ARG3 = número destino
exten => s,1,NoOp(Generando nombre de grabación: Tipo=${ARG1}, Origen=${ARG2}, Destino=${ARG3})
; Crear timestamp con formato YYYYMMDD-HHMMSS
same => n,Set(TIMESTAMP=${STRFTIME(${EPOCH},,%Y%m%d-%H%M%S)})
; Limpiar números de caracteres especiales para nombres de archivo seguros
same => n,Set(ORIGEN_LIMPIO=${FILTER(0-9,${ARG2})})
same => n,Set(DESTINO_LIMPIO=${FILTER(0-9,${ARG3})})
; Construir nombre completo
same => n,Set(NOMBRE_GRABACION=${TIMESTAMP}-${ARG1}-${ORIGEN_LIMPIO}-${DESTINO_LIMPIO})
; Determinar subdirectorio según tipo
same => n,ExecIf($["${ARG1}"="ENTRANTE"]?Set(SUBDIR=inbound))
same => n,ExecIf($["${ARG1}"="SALIENTE"]?Set(SUBDIR=outbound))
same => n,ExecIf($["${ARG1}"="INTERNO"]?Set(SUBDIR=internal))
; Establecer variables globales para uso posterior
same => n,Set(__RECORDING_FILENAME=${NOMBRE_GRABACION}.wav)
same => n,Set(__RECORDING_PATH=/var/spool/asterisk/monitor/${SUBDIR}/${NOMBRE_GRABACION}.wav)
same => n,NoOp(Grabación configurada: ${RECORDING_PATH})
same => n,Return()
Terminal window
nano /etc/asterisk/extensions_salidas.conf

Actualizar todos los contextos de salida:

; /etc/asterisk/extensions_salidas.conf
; Contextos de salida con grabación automática y registro CDR
[salidas-fijo]
; Llamadas a teléfonos fijos en Perú (2-7 seguido de 6 dígitos)
exten => _[2-7]XXXXXX,1,NoOp(=== LLAMADA SALIENTE A FIJO PERUANO ===)
same => n,NoOp(Destino: ${EXTEN}, Origen: ${CALLERID(num)})
; Generar nombre de grabación usando nuestra función
same => n,Gosub(generar-nombre-grabacion,s,1(SALIENTE,${CALLERID(num)},${EXTEN}))
; Configurar CDR para clasificación y auditoría
same => n,Set(CDR(accountcode)=FIJO_PERU)
same => n,Set(CDR(userfield)=Llamada a fijo nacional)
; Establecer CallerID corporativo (desde variable global del Taller 7)
same => n,Set(CALLERID(num)=${IDTRUNK})
; Iniciar grabación (opción 'b' = empezar tras Answer del destino)
same => n,MixMonitor(${RECORDING_PATH:23},b) ; :23 remueve el path, solo nombre archivo
; Guardar ruta completa en CDR para auditoría
same => n,Set(CDR(recording_path)=${RECORDING_PATH})
; Realizar llamada a través de la troncal
same => n,Dial(PJSIP/${EXTEN}@mi-proveedor-sip,45,T)
; Finalizar
same => n,Hangup()
[salidas-celular]
; Llamadas a celulares en Perú (9 seguido de 8 dígitos)
exten => _9XXXXXXXX,1,NoOp(=== LLAMADA SALIENTE A CELULAR ===)
same => n,NoOp(Destino: ${EXTEN}, Origen original: ${CALLERID(num)})
; Generar nombre de grabación
same => n,Gosub(generar-nombre-grabacion,s,1(SALIENTE,${CALLERID(num)},${EXTEN}))
; Configurar CDR
same => n,Set(CDR(accountcode)=CELULAR_PERU)
same => n,Set(CDR(userfield)=Llamada a celular con CallerID rotativo)
; Aplicar CallerID dinámico (del Taller 5)
same => n,AGI(callerid_roulette.py)
same => n,NoOp(CallerID después de AGI: ${CALLERID(num)})
; Configurar idioma para locuciones
same => n,Set(CHANNEL(language)=es)
; Iniciar grabación y guardar ruta
same => n,MixMonitor(${RECORDING_PATH:23},b)
same => n,Set(CDR(recording_path)=${RECORDING_PATH})
; Realizar llamada
same => n,Dial(PJSIP/${EXTEN}@mi-proveedor-sip,45,T)
same => n,Hangup()
[salidas-ldn]
; Llamadas de larga distancia nacional (0 + código área + número)
exten => _0NX[2-7]XXXXX,1,NoOp(=== LLAMADA LDN (LARGA DISTANCIA NACIONAL) ===)
same => n,NoOp(Destino: ${EXTEN}, Origen: ${CALLERID(num)})
; Generar nombre de grabación
same => n,Gosub(generar-nombre-grabacion,s,1(SALIENTE,${CALLERID(num)},${EXTEN}))
; Configurar CDR
same => n,Set(CDR(accountcode)=LDN_PERU)
same => n,Set(CDR(userfield)=Larga distancia nacional)
same => n,Set(CALLERID(num)=${IDTRUNK})
; Grabación y llamada
same => n,MixMonitor(${RECORDING_PATH:23},b)
same => n,Set(CDR(recording_path)=${RECORDING_PATH})
same => n,Dial(PJSIP/${EXTEN}@mi-proveedor-sip,45,T)
same => n,Hangup()
[salidas-ldi]
; Llamadas internacionales (00 + código país + número)
exten => _00.,1,NoOp(=== LLAMADA LDI (INTERNACIONAL) ===)
same => n,NoOp(Destino: ${EXTEN}, Origen: ${CALLERID(num)})
; Generar nombre de grabación
same => n,Gosub(generar-nombre-grabacion,s,1(SALIENTE,${CALLERID(num)},${EXTEN}))
; Configurar CDR con mayor tiempo de ring para internacionales
same => n,Set(CDR(accountcode)=LDI)
same => n,Set(CDR(userfield)=Llamada internacional)
same => n,Set(CALLERID(num)=${IDTRUNK})
; Grabación y llamada (60 segundos de ring para internacionales)
same => n,MixMonitor(${RECORDING_PATH:23},b)
same => n,Set(CDR(recording_path)=${RECORDING_PATH})
same => n,Dial(PJSIP/${EXTEN}@mi-proveedor-sip,60,T)
same => n,Hangup()
; === CONTEXTO DE LLAMADAS ENTRANTES ===
[from-trunk]
; Llamadas que llegan desde la troncal SIP
exten => s,1,NoOp(=== LLAMADA ENTRANTE DESDE TRONCAL ===)
same => n,NoOp(CallerID entrante: ${CALLERID(num)} - ${CALLERID(name)})
; Generar nombre de grabación para llamada entrante
same => n,Gosub(generar-nombre-grabacion,s,1(ENTRANTE,${CALLERID(num)},${IDTRUNK}))
; Configurar CDR para llamada entrante
same => n,Set(CDR(accountcode)=ENTRANTE)
same => n,Set(CDR(userfield)=Llamada entrante desde ${CALLERID(num)})
; Configurar idioma y contestar
same => n,Set(CHANNEL(language)=es)
same => n,Answer()
same => n,Wait(1) ; Pausa para estabilizar el canal
; Iniciar grabación después de Answer
same => n,MixMonitor(${RECORDING_PATH:23},ab) ; 'a' = grabar todo, 'b' = tras Answer
same => n,Set(CDR(recording_path)=${RECORDING_PATH})
; Enrutar a extensión principal (puedes modificar según necesidades)
same => n,Dial(PJSIP/1001,30)
same => n,Hangup()

Para las llamadas locales, podemos grabar o no las llamadas:

Terminal window
; === CONTEXTO DE LLAMADAS INTERNAS (ACTUALIZADO) ===
[anexos-internos]
; Llamadas entre extensiones internas con grabación opcional
exten => _100X,1,NoOp(=== LLAMADA INTERNA ===)
same => n,NoOp(De: ${CALLERID(num)} hacia: ${EXTEN})
same => n,Set(BUZON=${EXTEN}) ; Guarda el número de extensión en variable BUZON
; Para llamadas internas, grabación es opcional
; Descomenta las siguientes líneas si deseas grabar llamadas internas
same => n,Gosub(generar-nombre-grabacion,s,1(INTERNO,${CALLERID(num)},${EXTEN}))
same => n,Set(CDR(accountcode)=INTERNO)
same => n,Set(CDR(userfield)=Llamada interna)
same => n,MixMonitor(${RECORDING_PATH:23},b)
same => n,Set(CDR(recording_path)=${RECORDING_PATH})
; Llamar a la extensión con timeout de 20 segundos
same => n,Dial(PJSIP/${EXTEN},20,tT)
; Manejar casos donde no contesta, está ocupado, etc.
same => n,Goto(s-${DIALSTATUS},1)
; Manejo de estados de llamada interna
exten => s-BUSY,1,Voicemail(${BUZON}@default,b)
same => n,Hangup()
exten => s-NOANSWER,1,Voicemail(${BUZON}@default,u)
same => n,Hangup()
exten => _s-.,1,Goto(s-NOANSWER,1)
; [anexos-internos]
; ; Lógica para llamar entre extensiones internas (1001, 1002, 1003)
; exten => _100X,1,NoOp(Llamada interna a la extension ${EXTEN})
; same => n,Set(BUZON=${EXTEN}) ; Guarda el número de extensión en variable BUZON
; same => n,Dial(PJSIP/${EXTEN},20,tT)
; same => n,Goto(s-${DIALSTATUS},1) ; Salta a etiqueta según resultado
; exten => s-BUSY,1,Voicemail(${BUZON}@default,b) ; Ocupado -> anuncio "busy"
; same => n,Hangup()
; exten => s-NOANSWER,1,Voicemail(${BUZON}@default,u) ; No contesta -> "unavailable"
; same => n,Hangup()
; exten => _s-.,1,Goto(s-NOANSWER,1) ; Otros estados -> unavailable
; [anexos-internos]
; ; Lógica para llamar entre extensiones internas (1001, 1002, 1003)
; exten => _100X,1,NoOp(Llamada interna a la extension ${EXTEN})
; same => n,Dial(PJSIP/${EXTEN},30,tT)
; same => n,Hangup()

8.6 Carga, Verificación y Pruebas Iniciales

Sección titulada «8.6 Carga, Verificación y Pruebas Iniciales»
Terminal window
# Conectar al CLI con verbose alto para ver mensajes detallados
asterisk -rvvv

Dentro del CLI:

Terminal window
# Recargar módulos CDR y CEL
*CLI> module reload cdr_pgsql.so
*CLI> module reload cel_pgsql.so
# Recargar dialplan con nuevas configuraciones
*CLI> dialplan reload
# Verificar estado de módulos
*CLI> module show like pgsql
*CLI> cdr show status
*CLI> cel show status

Salida esperada de cdr show status:

Call Detail Record (CDR) settings
---------------------------------
Logging: Enabled
Mode: Simple
Log unanswered calls: No
Log congestion: No
* Registered Backends
-----------------------
pgsql

Salida esperada de cel show status:

CEL logging: Enabled
Backend: PostgreSQL
Table: cel
Filtering: Disabled

8.6.2 Verificar Conectividad a PostgreSQL desde Asterisk

Sección titulada «8.6.2 Verificar Conectividad a PostgreSQL desde Asterisk»
Terminal window
# Verificar logs de PostgreSQL para conexiones de Asterisk
tail -f /var/log/postgresql/postgresql-*-main.log

En otra terminal, dentro del CLI de Asterisk:

Terminal window
*CLI> module reload cdr_pgsql.so

Deberías ver en los logs de PostgreSQL:

LOG: connection received: host=[local]
LOG: connection authorized: user=asterisk database=aulaswitch application_name=asterisk-cdr
  1. Preparar monitoreo en tiempo real:
Terminal window
# Terminal 1: CLI de Asterisk
asterisk -rvvv
# Terminal 2: Logs de PostgreSQL
tail -f /var/log/postgresql/postgresql-*-main.log
# Terminal 3: Directorio de grabaciones
watch -n 1 "ls -la /var/spool/asterisk/monitor/*/"
  1. Realizar llamada de prueba desde extensión 1001 a un número celular (ej: 987654321)

  2. Observar en CLI mensajes como:

-- Executing [987654321@cat1:4] MixMonitor("PJSIP/1001-00000001", "20241028-143022-SALIENTE-1001-987654321.wav,b") in new stack
-- Begin MixMonitor Recording PJSIP/1001-00000001
Terminal window
# Salir del CLI de Asterisk
exit
# Conectar a PostgreSQL
psql -U asterisk -d aulaswitch -h localhost
-- Verificar último registro CDR
SELECT
calldate,
src,
dst,
duration,
billsec,
disposition,
accountcode,
recording_path
FROM cdr
ORDER BY calldate DESC
LIMIT 3;
-- Verificar eventos CEL de la última llamada
SELECT
eventtime,
eventtype,
cid_num,
exten,
context,
channame,
appname
FROM cel
WHERE uniqueid = (SELECT uniqueid FROM cdr ORDER BY calldate DESC LIMIT 1)
ORDER BY eventtime;
-- Estadísticas rápidas
SELECT
accountcode,
COUNT(*) as total_llamadas,
AVG(billsec) as duracion_promedio
FROM cdr
GROUP BY accountcode;
-- Salir
\q
Terminal window
# Listar grabaciones recientes
find /var/spool/asterisk/monitor/ -name "*.wav" -mmin -10 -ls
# Verificar propietario y tamaño
ls -lah /var/spool/asterisk/monitor/*/*.wav
# Reproducir grabación (si tienes sox instalado)
# apt install sox
# play /var/spool/asterisk/monitor/outbound/ARCHIVO.wav

Con CDR, CEL y grabaciones funcionando, podemos hacer análisis poderosos.

Terminal window
psql -U asterisk -d aulaswitch -h localhost
-- === REPORTE DIARIO EJECUTIVO ===
SELECT
DATE(calldate) as fecha,
COUNT(*) as total_llamadas,
SUM(CASE WHEN disposition = 'ANSWERED' THEN 1 ELSE 0 END) as contestadas,
SUM(CASE WHEN disposition = 'NO ANSWER' THEN 1 ELSE 0 END) as no_contestadas,
SUM(CASE WHEN disposition = 'BUSY' THEN 1 ELSE 0 END) as ocupadas,
SUM(CASE WHEN disposition = 'FAILED' THEN 1 ELSE 0 END) as fallidas,
ROUND(AVG(CASE WHEN disposition = 'ANSWERED' THEN billsec END), 2) as duracion_promedio,
SUM(CASE WHEN disposition = 'ANSWERED' THEN billsec ELSE 0 END) as tiempo_total_conversacion,
ROUND(
(SUM(CASE WHEN disposition = 'ANSWERED' THEN 1 ELSE 0 END) * 100.0 / COUNT(*)),
2
) as tasa_exito_porcentaje
FROM cdr
WHERE calldate >= CURRENT_DATE - INTERVAL '7 days'
GROUP BY DATE(calldate)
ORDER BY fecha DESC;
-- === ANÁLISIS POR TIPO DE LLAMADA ===
SELECT
accountcode as tipo_llamada,
COUNT(*) as cantidad,
ROUND(AVG(billsec)::numeric, 2) as duracion_promedio_seg,
ROUND(AVG(billsec)::numeric/60, 2) as duracion_promedio_min,
SUM(billsec) as tiempo_total_seg,
ROUND(SUM(billsec)::numeric/60, 2) as tiempo_total_min,
MIN(calldate) as primera_llamada,
MAX(calldate) as ultima_llamada
FROM cdr
WHERE calldate >= CURRENT_DATE - INTERVAL '30 days'
AND disposition = 'ANSWERED'
GROUP BY accountcode
ORDER BY cantidad DESC;
-- === TOP DESTINOS MÁS LLAMADOS ===
SELECT
dst as destino,
COUNT(*) as llamadas,
SUM(billsec) as tiempo_total_seg,
ROUND(AVG(billsec)::numeric, 2) as duracion_promedio,
SUM(CASE WHEN disposition = 'ANSWERED' THEN 1 ELSE 0 END) as exitosas,
ROUND(
(SUM(CASE WHEN disposition = 'ANSWERED' THEN 1 ELSE 0 END) * 100.0 / COUNT(*)),
2
) as tasa_exito
FROM cdr
WHERE calldate >= CURRENT_DATE - INTERVAL '30 days'
AND accountcode LIKE '%SALIENTE%'
GROUP BY dst
HAVING COUNT(*) >= 2 -- Solo destinos con al menos 2 llamadas
ORDER BY llamadas DESC
LIMIT 15;
-- === FLUJO COMPLETO DE UNA LLAMADA ESPECÍFICA ===
-- Reemplaza 'TU_UNIQUEID' con un uniqueid real de la tabla cdr
SELECT
to_char(eventtime, 'HH24:MI:SS.MS') as tiempo,
eventtype,
cid_num as callerid,
exten as extension,
context,
substring(channame from 1 for 25) as canal,
appname as aplicacion,
substring(appdata from 1 for 40) as parametros
FROM cel
WHERE uniqueid = (
SELECT uniqueid FROM cdr ORDER BY calldate DESC LIMIT 1
)
ORDER BY eventtime;
-- === LLAMADAS CON TRANSFERENCIAS ===
-- Identifica llamadas que fueron transferidas
SELECT DISTINCT
cdr.calldate,
cdr.src,
cdr.dst,
cdr.duration,
cdr.disposition,
COUNT(CASE WHEN cel.eventtype LIKE '%TRANSFER%' THEN 1 END) as transferencias
FROM cdr
JOIN cel ON cdr.uniqueid = cel.uniqueid
WHERE cel.eventtype LIKE '%TRANSFER%'
AND cdr.calldate >= CURRENT_DATE - INTERVAL '7 days'
GROUP BY cdr.uniqueid, cdr.calldate, cdr.src, cdr.dst, cdr.duration, cdr.disposition
ORDER BY cdr.calldate DESC;
-- === TIEMPO PROMEDIO DE RESPUESTA ===
-- Calcula cuánto tiempo tardan en contestar las llamadas
SELECT
accountcode,
COUNT(*) as llamadas_contestadas,
ROUND(AVG(EXTRACT(EPOCH FROM (answer.eventtime - start.eventtime))), 2) as tiempo_respuesta_promedio,
MIN(EXTRACT(EPOCH FROM (answer.eventtime - start.eventtime))) as tiempo_respuesta_min,
MAX(EXTRACT(EPOCH FROM (answer.eventtime - start.eventtime))) as tiempo_respuesta_max
FROM cdr
JOIN cel start ON cdr.uniqueid = start.uniqueid AND start.eventtype = 'CHAN_START'
JOIN cel answer ON cdr.uniqueid = answer.uniqueid AND answer.eventtype = 'ANSWER'
WHERE cdr.disposition = 'ANSWERED'
AND cdr.calldate >= CURRENT_DATE - INTERVAL '7 days'
GROUP BY accountcode
ORDER BY tiempo_respuesta_promedio;
-- === AUDITORÍA COMPLETA: CDR + CEL + GRABACIONES ===
-- Esta consulta correlaciona toda la información disponible
SELECT
cdr.calldate as fecha_hora,
cdr.src as origen,
cdr.dst as destino,
cdr.duration as duracion_total,
cdr.billsec as duracion_conversacion,
cdr.disposition as resultado,
cdr.accountcode as tipo_llamada,
cdr.userfield as descripcion,
-- Información de grabación
CASE
WHEN cdr.recording_path IS NOT NULL THEN 'Disponible'
ELSE 'No grabado'
END as grabacion_estado,
cdr.recording_path as archivo_audio,
-- Estadísticas derivadas de CEL
COUNT(cel.id) as total_eventos,
MIN(CASE WHEN cel.eventtype = 'CHAN_START' THEN cel.eventtime END) as inicio_canal,
MIN(CASE WHEN cel.eventtype = 'ANSWER' THEN cel.eventtime END) as momento_respuesta,
MAX(CASE WHEN cel.eventtype = 'HANGUP' THEN cel.eventtime END) as momento_colgado,
-- Análisis de transferencias y bridges
COUNT(CASE WHEN cel.eventtype LIKE '%TRANSFER%' THEN 1 END) as transferencias,
COUNT(CASE WHEN cel.eventtype LIKE 'BRIDGE_%' THEN 1 END) as eventos_bridge,
-- Identificadores para correlación
cdr.uniqueid as id_canal,
cdr.linkedid as id_llamada_logica
FROM cdr
LEFT JOIN cel ON cdr.uniqueid = cel.uniqueid
WHERE cdr.calldate >= CURRENT_DATE - INTERVAL '1 day'
GROUP BY
cdr.uniqueid, cdr.calldate, cdr.src, cdr.dst, cdr.duration,
cdr.billsec, cdr.disposition, cdr.accountcode, cdr.userfield,
cdr.recording_path, cdr.linkedid
ORDER BY cdr.calldate DESC;
-- === VERIFICAR INTEGRIDAD DE GRABACIONES ===
-- Identifica CDRs que deberían tener grabación pero no la tienen
SELECT
calldate,
src,
dst,
disposition,
accountcode,
CASE
WHEN recording_path IS NULL THEN 'Falta grabación'
WHEN recording_path = '' THEN 'Path vacío'
ELSE 'OK'
END as estado_grabacion
FROM cdr
WHERE calldate >= CURRENT_DATE - INTERVAL '1 day'
AND disposition = 'ANSWERED'
AND (recording_path IS NULL OR recording_path = '')
ORDER BY calldate DESC;

8.8 Scripts de Mantenimiento y Optimización

Sección titulada «8.8 Scripts de Mantenimiento y Optimización»

8.8.1 Script de Rotación y Compresión de Grabaciones

Sección titulada «8.8.1 Script de Rotación y Compresión de Grabaciones»
Terminal window
nano /usr/local/bin/manage_recordings.sh
#!/bin/bash
#
# Script de gestión integral de grabaciones de Asterisk
# Mantiene un equilibrio entre accesibilidad y espacio en disco
#
# === CONFIGURACIÓN ===
RECORDINGS_BASE="/var/spool/asterisk/monitor"
ARCHIVE_BASE="/var/backups/asterisk-recordings"
LOG_FILE="/var/log/asterisk/recordings_management.log"
# Políticas de retención (días)
DAYS_KEEP_ORIGINAL=7 # Mantener originales 7 días
DAYS_KEEP_COMPRESSED=90 # Mantener comprimidos 90 días
DAYS_KEEP_ARCHIVED=365 # Mantener archivados 1 año
# === FUNCIONES ===
log_message() {
echo "$(date '+%Y-%m-%d %H:%M:%S') - $1" | tee -a "$LOG_FILE"
}
create_directories() {
for dir in "$ARCHIVE_BASE"/{inbound,outbound,internal}/{compressed,archived}; do
mkdir -p "$dir"
done
chown -R asterisk:asterisk "$ARCHIVE_BASE"
}
compress_old_recordings() {
log_message "Iniciando compresión de grabaciones de más de $DAYS_KEEP_ORIGINAL días"
find "$RECORDINGS_BASE" -name "*.wav" -mtime +$DAYS_KEEP_ORIGINAL -type f | while read file; do
# Determinar directorio de destino basado en la estructura
subdir=$(dirname "$file" | sed "s|$RECORDINGS_BASE/||")
target_dir="$ARCHIVE_BASE/$subdir/compressed"
mkdir -p "$target_dir"
# Comprimir archivo
filename=$(basename "$file")
if gzip -c "$file" > "$target_dir/${filename}.gz"; then
rm "$file"
log_message "Comprimido y movido: $filename"
# Actualizar ruta en base de datos
old_path="$file"
new_path="$target_dir/${filename}.gz"
update_database_path "$old_path" "$new_path"
else
log_message "ERROR al comprimir: $filename"
fi
done
}
update_database_path() {
local old_path="$1"
local new_path="$2"
psql -U asterisk -d aulaswitch -h localhost -c "
UPDATE cdr
SET recording_path = '$new_path'
WHERE recording_path = '$old_path';
" 2>/dev/null
}
archive_old_compressed() {
log_message "Archivando grabaciones comprimidas de más de $DAYS_KEEP_COMPRESSED días"
find "$ARCHIVE_BASE" -path "*/compressed/*" -name "*.wav.gz" -mtime +$DAYS_KEEP_COMPRESSED -type f | while read file; do
# Mover a directorio archivado
archived_path=$(echo "$file" | sed 's|/compressed/|/archived/|')
archived_dir=$(dirname "$archived_path")
mkdir -p "$archived_dir"
if mv "$file" "$archived_path"; then
log_message "Archivado: $(basename $file)"
update_database_path "$file" "$archived_path"
fi
done
}
cleanup_old_archived() {
log_message "Eliminando archivos archivados de más de $DAYS_KEEP_ARCHIVED días"
find "$ARCHIVE_BASE" -path "*/archived/*" -name "*.wav.gz" -mtime +$DAYS_KEEP_ARCHIVED -type f -delete
# Limpiar registros CDR muy antiguos (mantener referencia pero sin archivo)
psql -U asterisk -d aulaswitch -h localhost -c "
UPDATE cdr
SET recording_path = 'ARCHIVED_' || recording_path
WHERE recording_path LIKE '$ARCHIVE_BASE%'
AND calldate < NOW() - INTERVAL '$DAYS_KEEP_ARCHIVED days';
" 2>/dev/null
}
generate_statistics() {
log_message "Generando estadísticas de espacio"
echo "=== ESTADÍSTICAS DE ALMACENAMIENTO ===" >> "$LOG_FILE"
du -sh "$RECORDINGS_BASE" "$ARCHIVE_BASE" 2>/dev/null >> "$LOG_FILE"
echo "=== CONTEO DE ARCHIVOS ===" >> "$LOG_FILE"
find "$RECORDINGS_BASE" -name "*.wav" | wc -l | xargs echo "Grabaciones originales:" >> "$LOG_FILE"
find "$ARCHIVE_BASE" -name "*.wav.gz" | wc -l | xargs echo "Grabaciones comprimidas:" >> "$LOG_FILE"
}
# === EJECUCIÓN PRINCIPAL ===
log_message "=== INICIO DE GESTIÓN DE GRABACIONES ==="
create_directories
compress_old_recordings
archive_old_compressed
cleanup_old_archived
generate_statistics
log_message "=== FIN DE GESTIÓN DE GRABACIONES ==="
Terminal window
chmod +x /usr/local/bin/manage_recordings.sh
# Programar ejecución diaria a las 2:30 AM
echo "30 2 * * * /usr/local/bin/manage_recordings.sh" | crontab -

8.8.2 Script de Optimización de Base de Datos

Sección titulada «8.8.2 Script de Optimización de Base de Datos»
Terminal window
nano /usr/local/bin/optimize_asterisk_db.sh
#!/bin/bash
#
# Script de optimización y mantenimiento de base de datos Asterisk
# Incluye vacuum, análisis de rendimiento y backup automático
#
DB_USER="asterisk"
DB_NAME="aulaswitch"
DB_HOST="localhost"
BACKUP_DIR="/var/backups/asterisk-db"
LOG_FILE="/var/log/asterisk/db_maintenance.log"
# Configuración de retención
CDR_RETENTION_DAYS=365
CEL_RETENTION_DAYS=90
log_message() {
echo "$(date '+%Y-%m-%d %H:%M:%S') - $1" | tee -a "$LOG_FILE"
}
create_backup() {
log_message "Creando backup de base de datos"
mkdir -p "$BACKUP_DIR"
backup_file="$BACKUP_DIR/aulaswitch-$(date +%Y%m%d-%H%M).sql.gz"
if pg_dump -U "$DB_USER" -h "$DB_HOST" "$DB_NAME" | gzip > "$backup_file"; then
log_message "Backup creado: $backup_file"
# Mantener solo backups de los últimos 30 días
find "$BACKUP_DIR" -name "*.sql.gz" -mtime +30 -delete
else
log_message "ERROR al crear backup"
exit 1
fi
}
analyze_performance() {
log_message "Analizando rendimiento de consultas"
psql -U "$DB_USER" -d "$DB_NAME" -h "$DB_HOST" << EOF >> "$LOG_FILE" 2>&1
-- Estadísticas de uso de índices
SELECT
schemaname,
tablename,
attname,
n_distinct,
correlation
FROM pg_stats
WHERE tablename IN ('cdr', 'cel')
ORDER BY tablename, attname;
-- Consultas lentas (si hay extensión pg_stat_statements)
SELECT
substring(query from 1 for 50) as query_start,
calls,
total_time,
mean_time,
rows
FROM pg_stat_statements
WHERE query LIKE '%cdr%' OR query LIKE '%cel%'
ORDER BY mean_time DESC
LIMIT 10;
-- Tamaño de tablas
SELECT
tablename,
pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) as size
FROM pg_tables
WHERE tablename IN ('cdr', 'cel');
EOF
}
cleanup_old_records() {
log_message "Iniciando limpieza de registros antiguos"
# Backup antes de eliminar
create_backup
# Limpiar registros CDR antiguos
cdr_deleted=$(psql -U "$DB_USER" -d "$DB_NAME" -h "$DB_HOST" -t -c "
WITH deleted AS (
DELETE FROM cdr
WHERE calldate < NOW() - INTERVAL '$CDR_RETENTION_DAYS days'
RETURNING 1
)
SELECT COUNT(*) FROM deleted;
")
log_message "CDR eliminados: $(echo $cdr_deleted | tr -d ' ')"
# Limpiar registros CEL antiguos
cel_deleted=$(psql -U "$DB_USER" -d "$DB_NAME" -h "$DB_HOST" -t -c "
WITH deleted AS (
DELETE FROM cel
WHERE eventtime < NOW() - INTERVAL '$CEL_RETENTION_DAYS days'
RETURNING 1
)
SELECT COUNT(*) FROM deleted;
")
log_message "CEL eliminados: $(echo $cel_deleted | tr -d ' ')"
}
optimize_tables() {
log_message "Optimizando tablas"
psql -U "$DB_USER" -d "$DB_NAME" -h "$DB_HOST" << EOF
-- Vacuum completo para recuperar espacio
VACUUM FULL cdr;
VACUUM FULL cel;
-- Analizar estadísticas para optimizar consultas
ANALYZE cdr;
ANALYZE cel;
-- Reindexar para mantener rendimiento
REINDEX TABLE cdr;
REINDEX TABLE cel;
EOF
log_message "Optimización completada"
}
check_table_health() {
log_message "Verificando salud de tablas"
psql -U "$DB_USER" -d "$DB_NAME" -h "$DB_HOST" << EOF >> "$LOG_FILE"
-- Verificar fragmentación de índices
SELECT
schemaname,
tablename,
indexname,
pg_size_pretty(pg_relation_size(indexrelid)) as index_size
FROM pg_stat_user_indexes
WHERE schemaname = 'public'
ORDER BY pg_relation_size(indexrelid) DESC;
-- Estadísticas de actividad de tablas
SELECT
schemaname,
tablename,
n_tup_ins as inserts,
n_tup_upd as updates,
n_tup_del as deletes,
last_vacuum,
last_autovacuum,
last_analyze,
last_autoanalyze
FROM pg_stat_user_tables
WHERE tablename IN ('cdr', 'cel');
EOF
}
# === EJECUCIÓN PRINCIPAL ===
log_message "=== INICIO DE MANTENIMIENTO DE BASE DE DATOS ==="
analyze_performance
cleanup_old_records
optimize_tables
check_table_health
log_message "=== FIN DE MANTENIMIENTO DE BASE DE DATOS ==="
Terminal window
chmod +x /usr/local/bin/optimize_asterisk_db.sh
# Ejecutar semanalmente los domingos a las 3:00 AM
echo "0 3 * * 0 /usr/local/bin/optimize_asterisk_db.sh" | crontab -
Terminal window
nano /usr/local/bin/monitor_asterisk_cdr.sh
#!/bin/bash
#
# Monitor en tiempo real del sistema de auditoría
# Detecta problemas y envía alertas
#
ALERT_EMAIL="admin@aulautil.com"
LOG_FILE="/var/log/asterisk/monitor_alerts.log"
check_cdr_writing() {
# Verificar que se estén escribiendo CDRs en los últimos 10 minutos
recent_cdr=$(psql -U asterisk -d aulaswitch -h localhost -t -c "
SELECT COUNT(*) FROM cdr
WHERE calldate >= NOW() - INTERVAL '10 minutes';
" 2>/dev/null | tr -d ' ')
if [ "$recent_cdr" = "0" ] && [ -n "$(asterisk -rx 'core show channels' | grep active)" ]; then
echo "$(date): ALERTA - No se están registrando CDRs pero hay llamadas activas" >> "$LOG_FILE"
return 1
fi
return 0
}
check_disk_space() {
usage=$(df /var/spool/asterisk/monitor | awk 'NR==2 {print $5}' | sed 's/%//')
if [ "$usage" -gt 85 ]; then
echo "$(date): ALERTA - Espacio de grabaciones al ${usage}%" >> "$LOG_FILE"
return 1
fi
return 0
}
check_postgres_connection() {
if ! psql -U asterisk -d aulaswitch -h localhost -c "SELECT 1;" >/dev/null 2>&1; then
echo "$(date): ALERTA - No se puede conectar a PostgreSQL" >> "$LOG_FILE"
return 1
fi
return 0
}
# Ejecutar verificaciones
alerts=0
check_cdr_writing || alerts=$((alerts + 1))
check_disk_space || alerts=$((alerts + 1))
check_postgres_connection || alerts=$((alerts + 1))
# Enviar email si hay alertas (requiere mailutils configurado)
if [ "$alerts" -gt 0 ]; then
tail -n 20 "$LOG_FILE" | mail -s "Alertas Asterisk CDR - $alerts problemas" "$ALERT_EMAIL"
fi
Terminal window
chmod +x /usr/local/bin/monitor_asterisk_cdr.sh
# Ejecutar cada 5 minutos
echo "*/5 * * * * /usr/local/bin/monitor_asterisk_cdr.sh" | crontab -

8.9.1 Consultas para Dashboard en Tiempo Real

Sección titulada «8.9.1 Consultas para Dashboard en Tiempo Real»
-- === MÉTRICAS EN TIEMPO REAL ===
-- Llamadas activas en este momento
SELECT
COUNT(*) as llamadas_activas,
COUNT(CASE WHEN cel.context LIKE 'salidas%' THEN 1 END) as salientes_activas,
COUNT(CASE WHEN cel.context = 'from-trunk' THEN 1 END) as entrantes_activas
FROM cel
WHERE eventtype = 'CHAN_START'
AND uniqueid NOT IN (
SELECT uniqueid FROM cel WHERE eventtype = 'HANGUP'
)
AND eventtime >= NOW() - INTERVAL '1 hour';
-- === ESTADÍSTICAS DEL DÍA ACTUAL ===
SELECT
'Hoy' as periodo,
COUNT(*) as total_llamadas,
SUM(CASE WHEN disposition = 'ANSWERED' THEN 1 ELSE 0 END) as contestadas,
SUM(CASE WHEN disposition = 'NO ANSWER' THEN 1 ELSE 0 END) as no_contestadas,
SUM(CASE WHEN disposition = 'BUSY' THEN 1 ELSE 0 END) as ocupadas,
SUM(CASE WHEN disposition = 'FAILED' THEN 1 ELSE 0 END) as fallidas,
ROUND(AVG(CASE WHEN disposition = 'ANSWERED' THEN billsec END), 2) as duracion_promedio,
SUM(CASE WHEN disposition = 'ANSWERED' THEN billsec ELSE 0 END) as tiempo_total_segundos,
ROUND(SUM(CASE WHEN disposition = 'ANSWERED' THEN billsec ELSE 0 END) / 60.0, 2) as tiempo_total_minutos
FROM cdr
WHERE DATE(calldate) = CURRENT_DATE;
-- === DISTRIBUCIÓN HORARIA ===
SELECT
EXTRACT(HOUR FROM calldate) as hora,
COUNT(*) as llamadas,
SUM(CASE WHEN disposition = 'ANSWERED' THEN 1 ELSE 0 END) as exitosas,
ROUND(AVG(CASE WHEN disposition = 'ANSWERED' THEN billsec END), 2) as duracion_promedio
FROM cdr
WHERE DATE(calldate) = CURRENT_DATE
GROUP BY EXTRACT(HOUR FROM calldate)
ORDER BY hora;
-- === TOP USUARIOS MÁS ACTIVOS ===
SELECT
src as extension,
COUNT(*) as llamadas_realizadas,
SUM(CASE WHEN disposition = 'ANSWERED' THEN 1 ELSE 0 END) as exitosas,
ROUND(AVG(CASE WHEN disposition = 'ANSWERED' THEN billsec END), 2) as duracion_promedio,
MAX(calldate) as ultima_llamada
FROM cdr
WHERE calldate >= CURRENT_DATE - INTERVAL '7 days'
AND src LIKE '100%' -- Solo extensiones internas
GROUP BY src
ORDER BY llamadas_realizadas DESC;
Terminal window
nano /usr/local/bin/daily_cdr_report.sh
#!/bin/bash
REPORT_FILE="/tmp/asterisk_daily_report_$(date +%Y%m%d).html"
EMAIL_RECIPIENT="admin@aulautil.com"
# Generar reporte HTML
cat > "$REPORT_FILE" << EOF
<!DOCTYPE html>
<html>
<head>
<title>Reporte Diario Asterisk - $(date '+%d/%m/%Y')</title>
<style>
body { font-family: Arial, sans-serif; margin: 20px; }
table { border-collapse: collapse; width: 100%; margin: 20px 0; }
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
th { background-color: #f2f2f2; }
.metric { background-color: #e7f3ff; padding: 15px; margin: 10px 0; border-radius: 5px; }
.alert { background-color: #ffebee; border-left: 4px solid #f44336; padding: 10px; }
</style>
</head>
<body>
<h1>📊 Reporte Diario de Asterisk - $(date '+%d/%m/%Y')</h1>
<div class="metric">
<h2>📈 Resumen Ejecutivo</h2>
EOF
# Agregar métricas principales
psql -U asterisk -d aulaswitch -h localhost -H -c "
SELECT
'Total de llamadas' as metrica,
COUNT(*) as valor
FROM cdr
WHERE DATE(calldate) = CURRENT_DATE
UNION ALL
SELECT
'Llamadas exitosas',
SUM(CASE WHEN disposition = 'ANSWERED' THEN 1 ELSE 0 END)
FROM cdr
WHERE DATE(calldate) = CURRENT_DATE
UNION ALL
SELECT
'Tiempo total conversación (min)',
ROUND(SUM(CASE WHEN disposition = 'ANSWERED' THEN billsec ELSE 0 END) / 60.0, 2)
FROM cdr
WHERE DATE(calldate) = CURRENT_DATE;
" >> "$REPORT_FILE"
# Continuar con más secciones del reporte...
cat >> "$REPORT_FILE" << EOF
</div>
<h2>📞 Top 5 Destinos Más Llamados</h2>
EOF
psql -U asterisk -d aulaswitch -h localhost -H -c "
SELECT dst as destino, COUNT(*) as llamadas
FROM cdr
WHERE DATE(calldate) = CURRENT_DATE AND disposition = 'ANSWERED'
GROUP BY dst
ORDER BY llamadas DESC
LIMIT 5;
" >> "$REPORT_FILE"
cat >> "$REPORT_FILE" << EOF
<h2>💽 Estado de Almacenamiento</h2>
<pre>$(df -h /var/spool/asterisk/monitor/)</pre>
<p><small>Reporte generado automáticamente el $(date)</small></p>
</body>
</html>
EOF
# Enviar por email
mail -a "Content-Type: text/html" -s "Reporte Diario Asterisk - $(date '+%d/%m/%Y')" "$EMAIL_RECIPIENT" < "$REPORT_FILE"
# Limpiar archivo temporal
rm "$REPORT_FILE"
Terminal window
chmod +x /usr/local/bin/daily_cdr_report.sh
# Enviar reporte diario a las 8:00 AM
echo "0 8 * * * /usr/local/bin/daily_cdr_report.sh" | crontab -

8.10 Troubleshooting y Optimización Avanzada

Sección titulada «8.10 Troubleshooting y Optimización Avanzada»

8.10.2 Optimización de Rendimiento PostgreSQL

Sección titulada «8.10.2 Optimización de Rendimiento PostgreSQL»
-- Configuraciones recomendadas para PostgreSQL con Asterisk
-- Ejecutar como usuario postgres
-- Aumentar memoria compartida para mejor rendimiento
ALTER SYSTEM SET shared_buffers = '256MB';
ALTER SYSTEM SET effective_cache_size = '1GB';
ALTER SYSTEM SET maintenance_work_mem = '64MB';
-- Optimizar para escrituras frecuentes (CDR/CEL)
ALTER SYSTEM SET wal_buffers = '16MB';
ALTER SYSTEM SET checkpoint_completion_target = 0.9;
ALTER SYSTEM SET max_wal_size = '1GB';
-- Configurar autovacuum más agresivo para tablas de alta inserción
ALTER TABLE cdr SET (autovacuum_vacuum_scale_factor = 0.1);
ALTER TABLE cel SET (autovacuum_vacuum_scale_factor = 0.05);
-- Aplicar cambios
SELECT pg_reload_conf();

8.10.3 Particionado de Tablas para Alto Volumen

Sección titulada «8.10.3 Particionado de Tablas para Alto Volumen»
-- Para sistemas con más de 100,000 llamadas/mes
-- Crear particionado por mes en tabla CDR
-- Convertir tabla CDR existente a particionada
BEGIN;
-- Crear nueva tabla CDR particionada
CREATE TABLE cdr_partitioned (LIKE cdr INCLUDING ALL) PARTITION BY RANGE (calldate);
-- Crear particiones para los próximos 12 meses
CREATE TABLE cdr_2025_01 PARTITION OF cdr_partitioned
FOR VALUES FROM ('2025-01-01') TO ('2025-02-01');
CREATE TABLE cdr_2025_02 PARTITION OF cdr_partitioned
FOR VALUES FROM ('2025-02-01') TO ('2025-03-01');
-- ... continuar para todos los meses
-- Migrar datos existentes
INSERT INTO cdr_partitioned SELECT * FROM cdr;
-- Renombrar tablas
ALTER TABLE cdr RENAME TO cdr_old;
ALTER TABLE cdr_partitioned RENAME TO cdr;
COMMIT;

8.10.4 Monitoreo de Rendimiento en Tiempo Real

Sección titulada «8.10.4 Monitoreo de Rendimiento en Tiempo Real»
Terminal window
# Script para monitorear el rendimiento del sistema CDR/CEL
nano /usr/local/bin/performance_monitor.sh
#!/bin/bash
echo "=== MONITOREO DE RENDIMIENTO ASTERISK CDR/CEL ==="
echo "Fecha: $(date)"
echo
echo "=== ESTADÍSTICAS DE ASTERISK ==="
asterisk -rx "core show calls"
asterisk -rx "cdr show status"
echo
echo "=== CONEXIONES POSTGRESQL ==="
psql -U asterisk -d aulaswitch -h localhost -c "
SELECT
application_name,
state,
COUNT(*) as connections
FROM pg_stat_activity
WHERE datname = 'aulaswitch'
GROUP BY application_name, state;
"
echo "=== ACTIVIDAD DE TABLAS ==="
psql -U asterisk -d aulaswitch -h localhost -c "
SELECT
schemaname,
tablename,
n_tup_ins as inserts,
n_tup_upd as updates,
n_tup_del as deletes
FROM pg_stat_user_tables
WHERE tablename IN ('cdr', 'cel');
"
echo "=== ESPACIO EN DISCO ==="
df -h /var/spool/asterisk/monitor/
du -sh /var/spool/asterisk/monitor/*/ 2>/dev/null
echo "=== REGISTROS RECIENTES ==="
psql -U asterisk -d aulaswitch -h localhost -c "
SELECT COUNT(*) as cdr_last_hour FROM cdr WHERE calldate >= NOW() - INTERVAL '1 hour';
"

  • Recompilación: Asterisk recompilado con libpq-dev y módulos nativos disponibles
  • PostgreSQL: Instalado, configurado con usuario asterisk y base aulaswitch
  • Módulos: cdr_pgsql.so y cel_pgsql.so cargados y funcionando
  • Configuración: cdr_pgsql.conf y cel_pgsql.conf con conexiones válidas
  • Tablas: CDR y CEL creadas con índices y permisos apropiados
  • MixMonitor: Integrado en dialplan con grabaciones organizadas por tipo
  • CDR personalizado: Campo recording_path registrando rutas de audio
  • Consultas: SQL de auditoría funcionando correctamente
  • Scripts: Mantenimiento automatizado programado en cron
  • Verificación: Al menos una llamada completa registrada y grabada

TareaComando
Instalar dependenciasapt install -y libpq-dev postgresql postgresql-client
Recompilar Asteriskcd /usr/src/asterisk-22.*; make clean; ./configure --with-jansson-bundled; make; make install
Verificar módulosasterisk -rx "module show like pgsql"
Estado CDR/CELasterisk -rx "cdr show status; cel show status"
Conectar PostgreSQLpsql -U asterisk -d aulaswitch -h localhost
Recargar módulosasterisk -rx "module reload cdr_pgsql.so; module reload cel_pgsql.so"
Ver grabacionesls -la /var/spool/asterisk/monitor/*/*.wav
Auditoría completapsql -U asterisk -d aulaswitch -h localhost -c "SELECT * FROM v_llamadas_completas;"
Mantenimiento manual/usr/local/bin/manage_recordings.sh; /usr/local/bin/optimize_asterisk_db.sh

🎯 Siguiente Paso

¡Excelente! Has construido un sistema de auditoría de clase enterprise usando módulos nativos PostgreSQL. Ahora tienes el pipeline de datos perfecto para el Taller 9, donde utilizaremos estos registros CDR/CEL para optimizar AMD (Answering Machine Detection) mediante análisis de audio con IA y machine learning.


¡Tu PBX ahora tiene visibilidad total y rendimiento nativo!