← All Tutorials

ViciDial Security Hardening — Complete Guide

ViciDial Administration Intermediate 73 min read #36

Tutorial 36: ViciDial Security Hardening — Complete Guide

The definitive guide to locking down ViciDial, Asterisk, and supporting infrastructure against real-world attacks

Field Value
Technologies ViciDial, Asterisk, fail2ban, UFW, iptables, Apache, MariaDB, Let's Encrypt, Loki
Difficulty Advanced
Reading Time ~75 minutes
Prerequisites Root SSH access, running ViciDial installation, basic Linux/Asterisk familiarity
Last Updated 2026-03-14

Table of Contents

Part 1 — Core Infrastructure Security

  1. Introduction — Why VoIP Servers Are Prime Targets
  2. Threat Model & Attack Surface
  3. SSH Hardening
  4. Firewall Configuration
  5. fail2ban Configuration
  6. Asterisk Security

Part 2 — Application & Operational Security

  1. ViciDial Web Security
  2. MariaDB Security
  3. Toll Fraud Prevention
  4. Monitoring & Alerting
  5. Backup & Recovery
  6. Security Audit Checklist
  7. Real Incident Case Studies

1. Introduction — Why VoIP Servers Are Prime Targets

ViciDial servers occupy a unique and dangerous intersection: they are telephony infrastructure with direct access to phone networks, web application servers exposing admin panels to the internet, and database servers holding customer PII. This combination makes them extraordinarily attractive to attackers.

The Four Categories of VoIP Attack

SIP Brute Force & Toll Fraud Every ViciDial server with port 5060 open receives SIP registration attempts within hours of going online. Automated scanners run 24/7, probing for weak SIP credentials. A single compromised SIP peer can generate thousands of dollars in international calls over a weekend — calls to premium-rate numbers in countries you have never heard of, routed through your trunks, billed to your account.

Web Application Exploits ViciDial's admin panel (/vicidial/) and agent interface (/agc/) are PHP applications with a history of vulnerabilities. In 2024, CVE-2024-8503 and CVE-2024-8504 demonstrated critical SQL injection leading to remote code execution — affecting every unpatched ViciDial installation worldwide. The admin panel often has no IP restrictions, no rate limiting, and runs over plain HTTP.

Asterisk Manager Interface (AMI) Exposure AMI (port 5038) provides complete control over Asterisk: originating calls, redirecting channels, reading configurations. Many installations leave AMI listening on all interfaces with weak passwords. An attacker with AMI access can monitor live calls, inject audio, and pivot to the underlying operating system.

Database Exploitation MariaDB/MySQL frequently listens on all interfaces (port 3306) in ViciDial clusters. Default installations may have users without passwords, overly broad permissions, or no query timeouts — enabling data exfiltration, denial of service via resource exhaustion, or privilege escalation.

What This Guide Covers

This is not a theoretical security guide. Every configuration in this tutorial has been tested on production ViciDial servers handling thousands of calls per day. The fail2ban rules have caught real SIP brute-force attacks. The firewall configurations have blocked real scanning campaigns. The incident case studies at the end document real attacks that were either prevented or (in one case) caused real damage before being fixed.

By the end of this guide, your ViciDial server will have:

A word on scope: This guide focuses on the ViciDial server itself. Network-level security (VPN, jump boxes, network segmentation) is mentioned where relevant but is not the primary focus.


2. Threat Model & Attack Surface

Before hardening anything, you need to understand what you are defending and against whom. This section maps the complete attack surface of a typical ViciDial server.

Attack Surface Map

Port/Service Purpose Attack Vector Severity Mitigation Section
22/9322 (SSH) Remote administration Brute force, credential stuffing Critical Section 3
5060-5061 (SIP) VoIP signaling Registration brute force, toll fraud Critical Section 4, Section 6
10000-20000 (RTP) Voice media Eavesdropping, injection High Section 4
80/443 (HTTP/S) Agent + admin panels SQLi, XSS, RCE, brute force Critical Section 7
5038 (AMI) Asterisk management Command injection, call hijacking Critical Section 6
3306 (MySQL) Database Data exfiltration, DoS, privilege escalation Critical Section 8
Cron jobs Automated tasks Persistence, privilege escalation Medium Section 12
Recording files Call recordings Unauthorized access, data theft High Section 7

Threat Actors

Actor Motivation Typical Attack Sophistication
SIP scanners (botnets) Toll fraud revenue Automated SIP REGISTER brute force Low — high volume, dumb patterns
Web vulnerability scanners Initial access / data theft SQLi, path traversal, CVE exploits Medium — use published exploits
Competitors / insiders Espionage, sabotage Credential theft, recording access Medium — know the system
Targeted attackers Persistent access, data Multi-stage: web exploit → shell → pivot High — custom tools

Risk Priority Matrix

Focus your hardening efforts in this order:

  1. SSH — if they get a shell, everything else is irrelevant
  2. SIP/Asterisk — toll fraud has immediate, measurable financial impact
  3. Web panels — the most frequently exploited entry point (CVE-2024-8503)
  4. Database — often the ultimate target; contains everything
  5. AMI — critical but less commonly exposed externally
  6. Monitoring — not an attack vector but essential for detecting everything above

3. SSH Hardening

SSH is the front door to your server. If an attacker gets SSH access, every other security measure is bypassed. This section implements defense in depth: the connection itself is hardened, authentication is restricted, and automated monitoring catches brute-force attempts.

3.1 Move SSH to a Non-Standard Port

Port 22 receives constant automated scanning. Moving to a non-standard port eliminates 99% of automated noise. This is not security through obscurity — it is noise reduction that makes your fail2ban logs actually useful.

# Backup the current config
cp /etc/ssh/sshd_config /etc/ssh/sshd_config.bak.$(date +%Y%m%d)

# Edit SSH config
vi /etc/ssh/sshd_config

Change or add:

Port 9322

Warning: Before restarting SSH, ensure your firewall allows the new port. Otherwise you will lock yourself out.

# If using UFW
ufw allow 9322/tcp comment "SSH custom port"

# If using iptables directly
iptables -I INPUT -p tcp --dport 9322 -j ACCEPT

# Test the config before restarting
sshd -t

# Restart SSH
systemctl restart sshd

Verify by opening a NEW terminal and connecting on the new port before closing your existing session:

ssh -p 9322 root@YOUR_SERVER_IP

3.2 Key-Only Authentication

Password authentication is the single biggest SSH vulnerability. Disable it completely.

Generate a key pair on your local machine (if you do not already have one):

ssh-keygen -t ed25519 -C "[email protected]"

Copy the public key to the server:

ssh-copy-id -p 9322 root@YOUR_SERVER_IP

Verify key login works in a new terminal before disabling passwords:

ssh -p 9322 -i ~/.ssh/id_ed25519 root@YOUR_SERVER_IP

Disable password authentication:

vi /etc/ssh/sshd_config
# Authentication
PermitRootLogin prohibit-password    # Allow root only with key (or use 'yes' if needed)
PasswordAuthentication no
ChallengeResponseAuthentication no
UsePAM yes                           # Keep PAM for session/env handling
PubkeyAuthentication yes
AuthorizedKeysFile .ssh/authorized_keys

# Restrict users (optional but recommended)
AllowUsers root admin

# Security tweaks
MaxAuthTries 3
LoginGraceTime 30
ClientAliveInterval 300
ClientAliveCountMax 2
X11Forwarding no
# Validate and restart
sshd -t && systemctl restart sshd

3.3 SSH Connection Multiplexing (ControlMaster)

For operational efficiency, configure SSH connection multiplexing on your management machine. This reuses a single TCP connection for multiple SSH sessions, reducing authentication overhead and connection time.

Add to ~/.ssh/config on your local/management machine:

Host vicidial-prod
    HostName YOUR_SERVER_IP
    Port 9322
    User root
    IdentityFile ~/.ssh/id_ed25519
    ControlMaster auto
    ControlPath ~/.ssh/sockets/%r@%h-%p
    ControlPersist 600
    ServerAliveInterval 60
    ServerAliveCountMax 3

# Wildcard for all ViciDial servers
Host vici-*
    Port 9322
    User root
    IdentityFile ~/.ssh/id_ed25519
    ControlMaster auto
    ControlPath ~/.ssh/sockets/%r@%h-%p
    ControlPersist 600
# Create the sockets directory
mkdir -p ~/.ssh/sockets
chmod 700 ~/.ssh/sockets

3.4 fail2ban SSH Jail

The SSH fail2ban jail catches brute-force attempts that make it past the non-standard port and key-only restrictions (e.g., scanning for the custom port, or if password auth is re-enabled temporarily).

Create /etc/fail2ban/jail.d/ssh.local:

[sshd]
enabled  = true
port     = 9322
filter   = sshd
logpath  = /var/log/auth.log
# For CentOS/RHEL:
# logpath = /var/log/secure
maxretry = 3
findtime = 600
bantime  = 3600
banaction = iptables-multiport
# Test the filter
fail2ban-regex /var/log/auth.log /etc/fail2ban/filter.d/sshd.conf

# Restart fail2ban
systemctl restart fail2ban
fail2ban-client status sshd

3.5 Optional: Two-Factor Authentication with Google Authenticator

For environments requiring an additional authentication factor, you can add TOTP (Time-based One-Time Password) to SSH. This means an attacker needs both your private key AND your phone to log in.

Note: This is optional and adds operational complexity. If you are the sole administrator, key-only auth is typically sufficient. For teams, 2FA adds significant value.

# Install on Debian/Ubuntu
apt install libpam-google-authenticator

# Install on CentOS/RHEL
yum install google-authenticator

# Install on openSUSE (ViciBox)
zypper install google-authenticator-libpam

Run the setup as the user who will log in:

google-authenticator

Answer the prompts:

Do you want authentication tokens to be time-based? y
# Scan the QR code with Google Authenticator, Authy, or similar
Do you want me to update your "~/.google_authenticator" file? y
Do you want to disallow multiple uses of the same token? y
Do you want to increase the time window? n
Do you want to enable rate-limiting? y

Configure PAM — edit /etc/pam.d/sshd:

# Add at the TOP of the file (before other auth lines)
auth required pam_google_authenticator.so nullok

The nullok flag allows users who have not set up 2FA to still log in (remove it once all users have configured their tokens).

Configure SSH to require both key AND TOTP:

vi /etc/ssh/sshd_config
# Enable both publickey and keyboard-interactive
AuthenticationMethods publickey,keyboard-interactive
ChallengeResponseAuthentication yes
UsePAM yes
sshd -t && systemctl restart sshd

Test in a new terminal before closing your current session:

ssh -p 9322 root@YOUR_SERVER_IP
# You should be prompted for your TOTP code after key auth

Backup your emergency codes. The google-authenticator setup produces 5 emergency scratch codes. Store these securely — if you lose your phone, these are your only way in.


4. Firewall Configuration

A properly configured firewall is the most effective single security measure. It reduces your attack surface from "every listening port" to "only the ports that need to be reachable." For ViciDial, this means SIP, RTP, HTTP, and SSH — nothing else should be accessible from the internet.

4.1 Understanding ViciDial Port Requirements

Port Protocol Purpose Who Needs Access
9322 (custom SSH) TCP Server administration Admin IPs only
80 TCP Agent panel (HTTP) Agent IPs / VPN range
443 TCP Agent panel (HTTPS) Agent IPs / VPN range
5060 UDP/TCP SIP signaling SIP trunk provider IPs only
5061 TCP SIP TLS signaling SIP trunk provider IPs only
10000-20000 UDP RTP media (voice) SIP trunk provider IPs + agent IPs
5038 TCP AMI (Asterisk Manager) localhost only
3306 TCP MySQL/MariaDB localhost or cluster nodes only
4569 UDP IAX2 (inter-server) ViciDial cluster nodes only

4.2 UFW Configuration (Recommended for Debian/Ubuntu)

UFW (Uncomplicated Firewall) provides a clean interface over iptables. If your ViciDial server is on Debian, Ubuntu, or a system with UFW available, this is the simplest approach.

# Install UFW if not present
apt install ufw    # Debian/Ubuntu
# or
zypper install ufw # openSUSE

# Reset to clean state (careful on remote servers!)
# ufw reset

# Default policies: deny incoming, allow outgoing
ufw default deny incoming
ufw default allow outgoing

# SSH (ALWAYS add this FIRST before enabling UFW)
ufw allow 9322/tcp comment "SSH custom port"

# HTTP/HTTPS for agent/admin panels
# Option A: Allow from anywhere (if agents connect from various locations)
ufw allow 80/tcp comment "HTTP - agent panel"
ufw allow 443/tcp comment "HTTPS - agent panel"

# Option B: Restrict to specific IP ranges (more secure)
ufw allow from 10.0.0.0/8 to any port 80 proto tcp comment "HTTP - VPN agents"
ufw allow from 10.0.0.0/8 to any port 443 proto tcp comment "HTTPS - VPN agents"
ufw allow from YOUR_OFFICE_IP to any port 80 proto tcp comment "HTTP - office"
ufw allow from YOUR_OFFICE_IP to any port 443 proto tcp comment "HTTPS - office"

# SIP - restrict to trunk provider IPs ONLY
ufw allow from TRUNK_PROVIDER_IP_1 to any port 5060 proto udp comment "SIP - Provider1"
ufw allow from TRUNK_PROVIDER_IP_2 to any port 5060 proto udp comment "SIP - Provider2"
# Never do: ufw allow 5060/udp  (this opens SIP to the world)

# RTP media range
ufw allow 10000:20000/udp comment "RTP media"

# IAX2 for ViciDial cluster (only if multi-server)
ufw allow from CLUSTER_NODE_IP to any port 4569 proto udp comment "IAX2 cluster"

# MySQL cluster replication (only if multi-server)
ufw allow from CLUSTER_NODE_IP to any port 3306 proto tcp comment "MySQL cluster"

# AMI - localhost only (should already be blocked by default deny)
# Explicitly ensure it is not opened:
ufw deny 5038 comment "AMI - localhost only via default deny"

# Enable UFW
ufw enable

# Verify rules
ufw status verbose
ufw status numbered

4.3 iptables Configuration (CentOS/RHEL/ViciBox)

For servers without UFW, or where you need more granular control, use iptables directly. This is common on ViciBox (openSUSE) and CentOS installations.

Create /etc/iptables/rules.v4 (or apply directly):

#!/bin/bash
# ViciDial Server Firewall Rules
# Save existing rules first
iptables-save > /root/iptables-backup-$(date +%Y%m%d).rules

# Flush existing rules
iptables -F
iptables -X
iptables -Z

# Default policies
iptables -P INPUT DROP
iptables -P FORWARD DROP
iptables -P OUTPUT ACCEPT

# Loopback (required for AMI, MySQL localhost, etc.)
iptables -A INPUT -i lo -j ACCEPT

# Established/related connections (critical for return traffic)
iptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT

# SSH on custom port
iptables -A INPUT -p tcp --dport 9322 -j ACCEPT

# HTTP/HTTPS
iptables -A INPUT -p tcp --dport 80 -j ACCEPT
iptables -A INPUT -p tcp --dport 443 -j ACCEPT

# SIP - restricted to trunk provider IPs
iptables -A INPUT -p udp --dport 5060 -s TRUNK_PROVIDER_IP_1 -j ACCEPT
iptables -A INPUT -p udp --dport 5060 -s TRUNK_PROVIDER_IP_2 -j ACCEPT
# Block all other SIP
iptables -A INPUT -p udp --dport 5060 -j DROP

# RTP media range
iptables -A INPUT -p udp --dport 10000:20000 -j ACCEPT

# IAX2 cluster (multi-server only)
iptables -A INPUT -p udp --dport 4569 -s CLUSTER_NODE_IP -j ACCEPT

# MySQL cluster (multi-server only)
iptables -A INPUT -p tcp --dport 3306 -s CLUSTER_NODE_IP -j ACCEPT

# ICMP (ping) - optional, useful for monitoring
iptables -A INPUT -p icmp --icmp-type echo-request -j ACCEPT

# Log dropped packets (optional, can be noisy)
iptables -A INPUT -m limit --limit 5/min -j LOG --log-prefix "iptables-dropped: " --log-level 4

# Drop everything else
iptables -A INPUT -j DROP

Save the rules persistently:

# Debian/Ubuntu
apt install iptables-persistent
netfilter-persistent save

# CentOS/RHEL
service iptables save

# openSUSE
iptables-save > /etc/sysconfig/iptables

4.4 SIP Rate Limiting

Even with SIP restricted to known trunk IPs, rate limiting prevents abuse if a trunk provider's network is compromised:

# Limit SIP registrations to 15 per minute per source IP
iptables -A INPUT -p udp --dport 5060 -m recent --name SIP --set
iptables -A INPUT -p udp --dport 5060 -m recent --name SIP \
  --update --seconds 60 --hitcount 15 -j DROP

For more sophisticated SIP rate limiting, use the hashlimit module:

# Allow max 20 SIP packets/sec per source IP, burst of 30
iptables -A INPUT -p udp --dport 5060 \
  -m hashlimit --hashlimit-name sip_rate \
  --hashlimit-above 20/sec --hashlimit-burst 30 \
  --hashlimit-mode srcip -j DROP

4.5 GeoIP Blocking

If your SIP trunks and agents are all in specific countries, you can block entire geographic regions. This dramatically reduces scanning noise.

# Install xtables-addons for GeoIP support
# Debian/Ubuntu
apt install xtables-addons-common libtext-csv-xs-perl

# Download GeoIP database
mkdir -p /usr/share/xt_geoip
cd /usr/share/xt_geoip
/usr/lib/xtables-addons/xt_geoip_dl
/usr/lib/xtables-addons/xt_geoip_build -D /usr/share/xt_geoip *.csv
# Block SIP from high-fraud countries (adjust to your needs)
# Common toll fraud sources — only apply to SIP port
iptables -A INPUT -p udp --dport 5060 \
  -m geoip --src-cc PH,NG,UA,RU,CN,VN,KP,IR,SY,SD \
  -j DROP

# Or: allow SIP ONLY from specific countries
iptables -A INPUT -p udp --dport 5060 \
  -m geoip ! --src-cc GB,IT,US,DE,NL \
  -j DROP

Note: GeoIP blocking is a supplementary measure. It should never be your only SIP protection — always restrict SIP to specific trunk provider IPs when possible.

4.6 Common Firewall Mistakes

These are real mistakes found on production ViciDial servers:

Mistake 1: The ACCEPT ALL Rule

# WRONG — This rule makes ALL subsequent rules ineffective
iptables -A INPUT -j ACCEPT    # Rule 1: Accept everything
iptables -A INPUT -p tcp --dport 3306 -j DROP  # Rule 2: Never reached!

iptables processes rules top-to-bottom and stops at the first match. An early ACCEPT ALL rule means nothing below it will ever be evaluated. Check for this with:

# Look for ACCEPT rules with no conditions
iptables -L INPUT -n --line-numbers | grep "ACCEPT     all"

If you find one, remove it by rule number:

iptables -D INPUT [RULE_NUMBER]

Mistake 2: Opening SIP to the World

# WRONG — Invites every SIP scanner on the internet
ufw allow 5060/udp

# RIGHT — Restrict to known trunk providers
ufw allow from 203.0.113.10 to any port 5060 proto udp

Mistake 3: Forgetting to Save Rules

iptables rules are lost on reboot unless explicitly saved. After every change:

# Verify rules are persisted
iptables-save | wc -l  # Should show your rule count
# Check if persistence mechanism is installed
systemctl status netfilter-persistent 2>/dev/null || echo "Not installed"

Mistake 4: Opening MySQL to All Interfaces

ViciDial's default my.cnf may have bind-address = 0.0.0.0 for cluster support. If you are running a single server:

# In /etc/my.cnf or /etc/mysql/my.cnf
[mysqld]
bind-address = 127.0.0.1

4.7 Verifying Your Firewall

Always verify from an external machine after making changes:

# From another server, scan your ViciDial server
nmap -sS -sU -p 22,80,443,3306,5038,5060,9322 YOUR_SERVER_IP

# Expected results:
# 9322/tcp  open     (SSH - your custom port)
# 80/tcp    open     (HTTP - if allowed)
# 443/tcp   open     (HTTPS - if allowed)
# 5060/udp  filtered (SIP - only open to trunk IPs)
# 3306/tcp  filtered (MySQL - blocked externally)
# 5038/tcp  filtered (AMI - blocked externally)
# 22/tcp    filtered (old SSH port - blocked)

5. fail2ban Configuration

fail2ban is your automated intrusion response system. It monitors log files for patterns of malicious activity and temporarily (or permanently) bans offending IP addresses. For a ViciDial server, you need multiple jails covering different attack vectors.

5.1 Installation and Base Configuration

# Debian/Ubuntu
apt install fail2ban

# CentOS/RHEL
yum install epel-release
yum install fail2ban

# openSUSE (ViciBox)
zypper install fail2ban

# Enable and start
systemctl enable fail2ban
systemctl start fail2ban

Create the base override configuration at /etc/fail2ban/jail.local:

[DEFAULT]
# Ban for 1 hour by default
bantime  = 3600
# Look at the last 10 minutes of logs
findtime = 600
# Ban after 5 failures
maxretry = 5
# Use iptables for banning
banaction = iptables-multiport
# Whitelist your management IPs (critical — do not lock yourself out)
ignoreip = 127.0.0.1/8 ::1 YOUR_MANAGEMENT_IP YOUR_OFFICE_IP

# Email notifications (optional)
# destemail = [email protected]
# sender = fail2ban@YOUR_SERVER_IP
# action = %(action_mwl)s

# Default action: ban and log
action = %(action_)s

5.2 Jail 1: Asterisk SIP Brute Force

This is the most critical jail. SIP brute-force attacks are constant and can lead to toll fraud within minutes of a successful breach.

Create the filter at /etc/fail2ban/filter.d/asterisk-security.conf:

# fail2ban filter for Asterisk SIP authentication failures
# Works with both chan_sip and PJSIP
# Tested on Asterisk 11.x through 20.x

[INCLUDES]
before = common.conf

[Definition]
_daemon = asterisk

# Pattern 1: chan_sip (legacy) — Registration/auth failures
# Example: NOTICE[12345]: chan_sip.c:12345 handle_request_register: Registration from '"100"<sip:100@YOUR_SERVER_IP>' failed for '192.168.1.100:5060' - Wrong password
# Example: NOTICE[12345]: chan_sip.c:12345 handle_request_register: Registration from '"100"<sip:100@YOUR_SERVER_IP>' failed for '192.168.1.100:5060' - No matching peer found

__chan_sip_fail = Registration from '.*' failed for '<HOST>(:\d+)?' - (?:Wrong password|No matching peer found|Username\/auth name mismatch|Not a local domain|Device does not match ACL|Peer is not supposed to register|ACL error)

# Pattern 2: PJSIP — Authentication failures
# Example: SECURITY[12345]: res_security_log.c:123 security_event_cb: SecurityEvent="FailedACL" ... RemoteAddress="IPV4/UDP/192.168.1.100/5060"
# Example: SECURITY[12345]: res_security_log.c:123 security_event_cb: SecurityEvent="InvalidAccountID" ... RemoteAddress="IPV4/UDP/192.168.1.100/5060"

__pjsip_fail_acl = SecurityEvent="FailedACL".*RemoteAddress="IPV4/(?:UDP|TCP|TLS)/<HOST>/\d+"
__pjsip_fail_auth = SecurityEvent="(?:InvalidAccountID|ChallengeResponseFailed|InvalidPassword)".*RemoteAddress="IPV4/(?:UDP|TCP|TLS)/<HOST>/\d+"

# Pattern 3: Asterisk security log (unified format)
# Example: NOTICE[12345][C-000001]: chan_sip.c:12345 handle_request_register: Registration from '"test"<sip:[email protected]>' failed for '192.168.1.100:5060' - No matching peer found

__ast_register_fail = Registration from '.*' failed for '<HOST>(:\d+)?'

# Pattern 4: PJSIP specific authentication failures
# Example: WARNING[12345]: res_pjsip/pjsip_distributor.c:660 log_failed_request: Request 'REGISTER' from '"<sip:[email protected]>" <sip:[email protected]>' failed for '192.168.1.100:5060' ... - Failed to authenticate

__pjsip_register_fail = Request '(?:REGISTER|INVITE)' from '.*' failed for '<HOST>(:\d+)?'

# Pattern 5: AMI authentication failures (covered by dedicated jail too)
__ami_fail = (?:failed to authenticate|Manager .* failed|host <HOST> failed to authenticate)

# Combined failregex
failregex = ^%(__prefix_line)s%(__chan_sip_fail)s\s*$
            ^%(__prefix_line)s%(__pjsip_fail_acl)s\s*$
            ^%(__prefix_line)s%(__pjsip_fail_auth)s\s*$
            ^.*%(__ast_register_fail)s\s*$
            ^.*%(__pjsip_register_fail)s\s*$

ignoreregex =

# Date detection (Asterisk uses various formats)
datepattern = {^LN-BEG}

Create the jail at /etc/fail2ban/jail.d/asterisk.local:

[asterisk-security]
enabled  = true
filter   = asterisk-security
# Check both log locations — Asterisk versions vary
logpath  = /var/log/asterisk/messages
           /var/log/asterisk/security
           /var/log/asterisk/full
port     = 5060,5061
protocol = udp
maxretry = 3
findtime = 300
# Ban for 24 hours — SIP brute force is always malicious
bantime  = 86400
# Use allports to block the attacker from everything, not just SIP
banaction = iptables-allports

5.3 Jail 2: Apache/Web Brute Force

Protects the ViciDial admin and agent panels from web-based brute force and scanning.

Create the filter at /etc/fail2ban/filter.d/apache-viciauth.conf:

# fail2ban filter for Apache authentication failures targeting ViciDial
# Catches: admin panel brute force, agent login attempts, API abuse

[INCLUDES]
before = common.conf

[Definition]

# Pattern 1: HTTP 401 Unauthorized (Basic Auth failures)
__http_401 = <HOST> -.*"(GET|POST) /vicidial/.* HTTP/.*" 401

# Pattern 2: Repeated 403 Forbidden (ACL violations)
__http_403 = <HOST> -.*"(GET|POST) /(vicidial|agc)/.* HTTP/.*" 403

# Pattern 3: Known vulnerability scanners (path traversal, etc.)
__scanners = <HOST> -.*"(GET|POST) .*(\.\.\/|etc\/passwd|wp-login|phpmyadmin|\.env|config\.php).*" \d+

# Pattern 4: Excessive 404s from a single IP (directory brute force)
__http_404_burst = <HOST> -.*"(GET|POST|HEAD) .* HTTP/.*" 404

failregex = ^%(__http_401)s$
            ^%(__http_403)s$
            ^%(__scanners)s$

ignoreregex = ^<HOST> -.* "(GET|POST) /(agc|vicidial)/(images|css|js|sounds)/

datepattern = {^LN-BEG}%%ExY
              ^[^\[]*\[({DATE})
              {^LN-BEG}

Create the jail at /etc/fail2ban/jail.d/apache-vici.local:

[apache-viciauth]
enabled  = true
filter   = apache-viciauth
logpath  = /var/log/apache2/access_log
           /var/log/httpd/access_log
           /var/log/apache2/access.log
port     = http,https
maxretry = 10
findtime = 300
bantime  = 3600

5.4 Jail 3: ViciDial Admin Login

ViciDial's admin login page does not use HTTP Basic Auth — it has its own login form. Failed logins are logged differently, so we need a separate filter.

Create the filter at /etc/fail2ban/filter.d/vicidial-admin.conf:

# fail2ban filter for ViciDial admin panel login failures
# These appear in Apache access logs as POST to admin login with redirect

[INCLUDES]
before = common.conf

[Definition]
# ViciDial admin login failures return a 200 with "Invalid Username or Password"
# in the response, but we can catch them by POST pattern to the login page
# combined with subsequent requests showing no session

# Pattern: Repeated POST requests to the admin login page from same IP
__admin_post = <HOST> -.*"POST /vicidial/admin\.php HTTP/.*"

# Pattern: Failed API authentication
__api_fail = <HOST> -.*"(GET|POST) /vicidial/non_agent_api\.php\?.*" 401

failregex = ^%(__admin_post)s$
            ^%(__api_fail)s$

ignoreregex =

datepattern = {^LN-BEG}%%ExY
              ^[^\[]*\[({DATE})
              {^LN-BEG}

Create the jail at /etc/fail2ban/jail.d/vicidial-admin.local:

[vicidial-admin]
enabled  = true
filter   = vicidial-admin
logpath  = /var/log/apache2/access_log
           /var/log/httpd/access_log
           /var/log/apache2/access.log
port     = http,https
# Higher threshold — legitimate admins sometimes mistype
maxretry = 5
findtime = 300
bantime  = 7200

5.5 Jail 4: AMI Brute Force

The Asterisk Manager Interface should only be accessible from localhost, but if it is misconfigured, this jail provides a safety net.

Create the filter at /etc/fail2ban/filter.d/asterisk-ami.conf:

# fail2ban filter for Asterisk Manager Interface (AMI) auth failures

[INCLUDES]
before = common.conf

[Definition]
_daemon = asterisk

# AMI authentication failures
# Example: NOTICE[12345]: manager.c:3456 authenticate: 192.168.1.100 failed to authenticate as 'admin'
# Example: NOTICE[12345]: manager.c:3456 authenticate: host 192.168.1.100 failed to authenticate

failregex = ^.*\s<HOST> failed to authenticate as '.*'\s*$
            ^.*\shost <HOST> failed to authenticate\s*$
            ^.*Manager '.*' logged off from <HOST>\s*$
            ^.*<HOST> tried to authenticate with nonexistent user '.*'\s*$

ignoreregex =

datepattern = {^LN-BEG}

Create the jail at /etc/fail2ban/jail.d/asterisk-ami.local:

[asterisk-ami]
enabled  = true
filter   = asterisk-ami
logpath  = /var/log/asterisk/messages
           /var/log/asterisk/full
port     = 5038
maxretry = 2
findtime = 300
# AMI brute force is extremely suspicious — 48 hour ban
bantime  = 172800
banaction = iptables-allports

5.6 Jail 5: SSH (Enhanced)

An enhanced SSH jail with progressive banning — repeat offenders get longer bans.

Create /etc/fail2ban/jail.d/ssh-aggressive.local:

[sshd]
enabled  = true
port     = 9322
filter   = sshd
logpath  = /var/log/auth.log
maxretry = 3
findtime = 600
bantime  = 3600

# Recidive jail — catches repeat offenders across all jails
[recidive]
enabled  = true
filter   = recidive
logpath  = /var/log/fail2ban.log
# If banned 3 times across any jail within 24 hours
maxretry = 3
findtime = 86400
# Ban for 1 week
bantime  = 604800
banaction = iptables-allports

5.7 Custom Ban Actions

Email Notification on Ban

Create /etc/fail2ban/action.d/notify-email.conf:

# Custom action: send email notification when an IP is banned

[Definition]
actionstart =
actionstop =
actioncheck =

actionban = printf %%b "Subject: [fail2ban] <name>: banned <ip>
From: fail2ban@YOUR_SERVER_IP
To: [email protected]

The IP <ip> has been banned by fail2ban after <failures> failures
in jail <name>.

Server: YOUR_SERVER_HOSTNAME
Time: $(date)
Whois:
$(whois <ip> 2>/dev/null | head -20)" | sendmail [email protected]

actionunban =

Permanent Ban List

For IPs that are banned repeatedly, add them to a persistent blocklist:

Create /etc/fail2ban/action.d/permanent-ban.conf:

# Custom action: add to permanent ban list after recidive triggers

[Definition]
actionstart =
actionstop =
actioncheck =

actionban = grep -qxF '<ip>' /etc/fail2ban/ip.blocklist 2>/dev/null || \
            echo '<ip>' >> /etc/fail2ban/ip.blocklist
            iptables -I INPUT -s <ip> -j DROP

actionunban = echo "IP <ip> is on permanent blocklist — not unbanning"

[Init]

Create the blocklist restoration script at /usr/local/bin/fail2ban-restore-blocklist.sh:

#!/bin/bash
# Restore permanent ban list on server reboot
BLOCKLIST="/etc/fail2ban/ip.blocklist"

if [ -f "$BLOCKLIST" ]; then
    while IFS= read -r ip; do
        [ -z "$ip" ] && continue
        [[ "$ip" =~ ^# ]] && continue
        iptables -C INPUT -s "$ip" -j DROP 2>/dev/null || \
            iptables -I INPUT -s "$ip" -j DROP
    done < "$BLOCKLIST"
    echo "$(date): Restored $(wc -l < "$BLOCKLIST") permanent bans" >> /var/log/fail2ban-restore.log
fi
chmod +x /usr/local/bin/fail2ban-restore-blocklist.sh

# Add to crontab to restore on reboot
(crontab -l 2>/dev/null; echo "@reboot /usr/local/bin/fail2ban-restore-blocklist.sh") | crontab -

5.8 Testing and Monitoring fail2ban

Test each filter against your logs:

# Test Asterisk SIP filter
fail2ban-regex /var/log/asterisk/messages /etc/fail2ban/filter.d/asterisk-security.conf

# Test Apache filter
fail2ban-regex /var/log/apache2/access_log /etc/fail2ban/filter.d/apache-viciauth.conf

# Test AMI filter
fail2ban-regex /var/log/asterisk/messages /etc/fail2ban/filter.d/asterisk-ami.conf

Monitor active jails:

# Status of all jails
fail2ban-client status

# Detailed status of a specific jail
fail2ban-client status asterisk-security

# Currently banned IPs across all jails
fail2ban-client banned

# Unban a specific IP (if you accidentally ban yourself)
fail2ban-client set asterisk-security unbanip YOUR_IP

Daily ban summary script — save as /usr/local/bin/fail2ban-summary.sh:

#!/bin/bash
# Daily fail2ban summary report
echo "=== fail2ban Summary — $(date '+%Y-%m-%d %H:%M') ==="
echo ""

for jail in $(fail2ban-client status | grep "Jail list" | sed 's/.*://;s/,/ /g'); do
    total=$(fail2ban-client status "$jail" | grep "Total banned" | awk '{print $NF}')
    current=$(fail2ban-client status "$jail" | grep "Currently banned" | awk '{print $NF}')
    echo "  $jail: $current currently banned, $total total bans"
done

echo ""
echo "--- Top 10 Banned IPs (last 24h) ---"
grep "Ban " /var/log/fail2ban.log | \
    grep "$(date '+%Y-%m-%d')" | \
    awk '{print $NF}' | sort | uniq -c | sort -rn | head -10

echo ""
echo "--- Permanent Blocklist ---"
if [ -f /etc/fail2ban/ip.blocklist ]; then
    echo "$(wc -l < /etc/fail2ban/ip.blocklist) IPs permanently blocked"
else
    echo "No permanent blocklist configured"
fi
chmod +x /usr/local/bin/fail2ban-summary.sh

Whitelist management:

# Add an IP to the whitelist (survives restarts)
# Edit /etc/fail2ban/jail.local and add to ignoreip

# Temporarily whitelist during maintenance
fail2ban-client set asterisk-security addignoreip YOUR_MAINTENANCE_IP

6. Asterisk Security

Asterisk is the core telephony engine in ViciDial. Its security configuration determines whether attackers can make calls through your system, eavesdrop on conversations, or hijack call flows. This section covers SIP peer hardening, module pruning, AMI lockdown, and context separation.

6.1 SIP Peer Hardening

Every SIP peer (extension, trunk, softphone) should be configured with security in mind. The default ViciDial installation creates peers with minimal security settings.

Edit /etc/asterisk/sip.conf (global settings):

[general]
; Security settings
alwaysauthreject = yes          ; Always reject with same message (prevents username enumeration)
allowguest = no                 ; Reject calls from unregistered peers
allow_external_domains = no     ; Only accept calls to our domain
srvlookup = no                  ; Disable DNS SRV (prevents DNS-based attacks)

; TLS settings (if using encrypted SIP)
; tlsenable = yes
; tlscertfile = /etc/asterisk/keys/asterisk.pem
; tlscafile = /etc/asterisk/keys/ca.crt
; tlsdontverifyserver = no

; Registration security
registerattempts = 5            ; Limit registration attempts per peer
registertimeout = 30            ; Registration timeout in seconds
defaultexpiry = 120             ; Default registration expiry
maxexpiry = 300                 ; Maximum registration expiry
minexpiry = 60                  ; Minimum registration expiry

; Network security
externaddr = YOUR_PUBLIC_IP     ; Set your actual public IP
localnet = 10.0.0.0/8          ; Define local networks
localnet = 172.16.0.0/12
localnet = 192.168.0.0/16

; Connection limits
maxcallbitrate = 384            ; Max video bitrate (prevent resource exhaustion)
tcpauthlimit = 100              ; Max unauthenticated TCP connections
tcpauthtimeout = 30             ; Timeout for unauthenticated TCP connections

Individual SIP peer hardening (in sip-vicidial.conf or equivalent):

; Example: Hardened SIP trunk peer
[my_trunk]
type = peer
host = TRUNK_PROVIDER_IP
port = 5060
secret = USE_A_STRONG_32_CHAR_PASSWORD_HERE
context = trunkinbound          ; NEVER use 'default' or 'from-internal'
disallow = all
allow = ulaw
allow = alaw
qualify = yes
insecure = port,invite          ; Only if required by provider
; ACL restrictions
permit = TRUNK_PROVIDER_IP/32   ; Only allow from provider's IP
deny = 0.0.0.0/0               ; Deny all others

; Example: Hardened agent phone peer
[agent_phone_100]
type = friend
secret = R4nd0m$tr0ngP@ss!2026  ; Minimum 16 characters, mixed case + symbols
context = phones                 ; Restricted context — not 'default'
host = dynamic
qualify = yes
disallow = all
allow = ulaw
; ACL: only allow from VPN or office network
permit = 10.0.0.0/255.0.0.0
deny = 0.0.0.0/0.0.0.0
; Call limits
call-limit = 2                   ; Max concurrent calls per peer

Critical: The alwaysauthreject setting is essential. Without it, Asterisk returns different error messages for "valid username, wrong password" vs. "unknown username" — allowing attackers to enumerate valid SIP accounts before attempting passwords.

6.2 Strong SIP Passwords

ViciDial auto-generates SIP passwords, but they are often short and predictable. Generate strong passwords:

# Generate a strong SIP password (32 characters, no special chars that break SIP)
openssl rand -base64 24 | tr -d '+/=' | head -c 32
# Example output: 7kHm3nP9wQ2xR5vB8jL4cN6tY1uF0aE

# Generate passwords for multiple peers at once
for i in $(seq 100 200); do
    pass=$(openssl rand -base64 24 | tr -d '+/=' | head -c 24)
    echo "Peer $i: $pass"
done

Update passwords in ViciDial database:

-- Generate new passwords for all phones (ViciDial admin panel is preferred)
-- But for bulk operations:
UPDATE phones SET pass = CONCAT(
    SUBSTRING(MD5(RAND()), 1, 8),
    SUBSTRING(MD5(RAND()), 1, 8),
    SUBSTRING(MD5(RAND()), 1, 8)
) WHERE server_ip = 'YOUR_SERVER_IP';

Note: After changing SIP passwords, all agents will need to reconfigure their softphones. Plan this during a maintenance window.

6.3 Disable Unused Asterisk Modules

Every loaded module is potential attack surface. Disable modules you do not use.

Check currently loaded modules:

asterisk -rx "module show" | wc -l

Create /etc/asterisk/modules.conf (or edit existing):

[modules]
autoload = yes

; Disable modules not needed for ViciDial
; Channel drivers
noload => chan_skinny.so        ; Cisco Skinny protocol
noload => chan_mgcp.so          ; Media Gateway Control Protocol
noload => chan_unistim.so       ; Unistim phones
noload => chan_oss.so           ; Console audio
noload => chan_phone.so         ; Linux telephony interface
noload => chan_motif.so         ; Google Talk/Jingle
noload => chan_ooh323.so        ; H.323 protocol

; Resources not needed
noload => res_config_ldap.so   ; LDAP (unless using LDAP auth)
noload => res_snmp.so          ; SNMP (unless monitoring with SNMP)
noload => res_smdi.so          ; Simplified Message Desk Interface
noload => res_calendar.so      ; Calendar integration
noload => res_calendar_caldav.so
noload => res_calendar_icalendar.so
noload => res_xmpp.so          ; XMPP/Jabber (unless using chat)

; Applications not needed
noload => app_festival.so      ; Festival TTS
noload => app_mp3.so           ; MP3 playback
noload => app_flash.so         ; Flash hook
noload => app_dahdiras.so      ; DAHDI RAS
noload => app_ices.so          ; ICEcast streaming
noload => app_minivm.so        ; Mini voicemail (ViciDial has its own)
noload => app_zapateller.so    ; SIT tones

; Format codecs not needed (keep ulaw, alaw, wav, gsm)
noload => format_ogg_vorbis.so
noload => format_mp3.so

; CDR backends not needed (keep cdr_mysql)
noload => cdr_csv.so           ; CSV CDR (ViciDial uses MySQL)
noload => cdr_sqlite3_custom.so
noload => cdr_radius.so
noload => cdr_syslog.so
# Reload modules (does not require Asterisk restart)
asterisk -rx "core restart gracefully"
# Or to apply without dropping calls:
asterisk -rx "module reload"

6.4 AMI Security (Asterisk Manager Interface)

AMI provides full control over Asterisk. It must be locked down to localhost only and use strong credentials with minimal permissions.

Edit /etc/asterisk/manager.conf:

[general]
enabled = yes
port = 5038
; CRITICAL: Bind to localhost only
bindaddr = 127.0.0.1

; Reject connections after 3 failed auth attempts
authlimit = 3
authtimeout = 30

; Do not display Asterisk version in AMI responses (information disclosure)
; displayconnects = yes  ; Logged but not sent to AMI clients
httpenabled = no         ; Disable AMI over HTTP (unless specifically needed)
webenabled = no          ; Disable AMI web interface

; ViciDial AMI user — restrict permissions to only what ViciDial needs
[cron]
secret = STRONG_AMI_PASSWORD_32_CHARS_MINIMUM
deny = 0.0.0.0/0.0.0.0
permit = 127.0.0.1/255.255.255.0
; Only grant permissions ViciDial actually needs
read = system,call,log,agent,user,config,dtmf,reporting,cdr,dialplan
write = system,call,agent,user,config,command,reporting,originate
writetimeout = 5000

; Read-only monitoring user (for dashboards/Prometheus)
[monitor]
secret = DIFFERENT_STRONG_PASSWORD_HERE
deny = 0.0.0.0/0.0.0.0
permit = 127.0.0.1/255.255.255.0
; Read-only — cannot originate calls or change configuration
read = system,call,agent,reporting,cdr
write =
writetimeout = 5000

Verify AMI is only listening on localhost:

# Check AMI binding
ss -tlnp | grep 5038
# Should show: 127.0.0.1:5038 (NOT 0.0.0.0:5038)

# Test from another server (should fail)
# From external machine:
telnet YOUR_SERVER_IP 5038
# Should timeout / connection refused

6.5 Context Separation

Asterisk contexts control what callers/peers can do. Poor context design is a common source of toll fraud — an unauthenticated caller landing in a context that can dial external numbers.

Principles:

  1. Never use the default context for anything — create explicit named contexts
  2. Inbound trunks get their own context[trunkinbound], not [from-internal]
  3. Agent phones get a restricted context[phones] with only what they need
  4. Unauthenticated traffic goes to a dead-end context[blackhole]

Example context structure in extensions.conf:

; Dead-end context for unauthenticated traffic
[blackhole]
exten => _X.,1,NoOp(Unauthorized call attempt from ${CALLERID(all)})
same => n,Hangup(21)     ; 21 = Call Rejected

; Inbound trunk context — only routes to ViciDial
[trunkinbound]
; Only accept calls to known DIDs
exten => _X.,1,AGI(agi://127.0.0.1:4577/--HVcamd--Aession-server1)
same => n,Hangup()

; Agent phone context — restricted dialing
[phones]
; Internal extensions only
exten => _8XXX,1,Dial(SIP/${EXTEN},30)
same => n,Hangup()

; Emergency numbers (if applicable)
exten => _999,1,Dial(SIP/my_trunk/${EXTEN})
exten => _112,1,Dial(SIP/my_trunk/${EXTEN})

; Block everything else
exten => _X.,1,NoOp(Blocked outbound attempt: ${EXTEN} by ${CALLERID(all)})
same => n,Playback(pbx-invalid)
same => n,Hangup(21)

; ViciDial default context (set in ViciDial admin)
[default]
; Outbound calls from ViciDial campaigns — routed through authorized trunks
exten => _X.,1,AGI(agi://127.0.0.1:4577/--HVcamd--ASession-server1)
same => n,Hangup()

6.6 CDR and Log Permissions

Call Detail Records and Asterisk logs contain sensitive information (phone numbers, call durations, agent IDs). Restrict access:

# Restrict Asterisk log directory
chmod 750 /var/log/asterisk/
chown asterisk:asterisk /var/log/asterisk/

# Restrict CDR files
chmod 640 /var/log/asterisk/cdr-csv/*.csv 2>/dev/null

# Restrict Asterisk config directory
chmod 750 /etc/asterisk/
chown -R asterisk:asterisk /etc/asterisk/

# Restrict recording directory
chmod 750 /var/spool/asterisk/monitor/
chown asterisk:asterisk /var/spool/asterisk/monitor/

6.7 SIP TLS Overview

For environments requiring encrypted SIP signaling, Asterisk supports TLS. This prevents eavesdropping on SIP messages (which contain metadata like caller ID, called numbers, and codec negotiation).

Note: SIP TLS encrypts signaling only. For encrypted media (voice), you also need SRTP. Full TLS+SRTP setup is outside the scope of this guide but the certificate setup below is the foundation.

# Generate a self-signed certificate (or use Let's Encrypt)
mkdir -p /etc/asterisk/keys
cd /etc/asterisk/keys

# Generate private key
openssl genrsa -out asterisk.key 4096

# Generate certificate signing request
openssl req -new -key asterisk.key -out asterisk.csr \
    -subj "/CN=YOUR_SERVER_FQDN/O=YourCompany/C=GB"

# Self-sign for 1 year
openssl x509 -req -days 365 -in asterisk.csr \
    -signkey asterisk.key -out asterisk.crt

# Combine for Asterisk
cat asterisk.key asterisk.crt > asterisk.pem

# Set permissions
chown asterisk:asterisk /etc/asterisk/keys/*
chmod 600 /etc/asterisk/keys/asterisk.key
chmod 644 /etc/asterisk/keys/asterisk.crt

Enable in sip.conf:

[general]
tlsenable = yes
tlsbindaddr = 0.0.0.0:5061
tlscertfile = /etc/asterisk/keys/asterisk.pem
tlscafile = /etc/asterisk/keys/asterisk.crt
tlsdontverifyserver = no
tlscipher = ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256

For individual peers:

[secure_trunk]
transport = tls
encryption = yes      ; Enables SRTP
# Verify TLS is listening
asterisk -rx "sip show settings" | grep TLS
# Should show: TLS Enabled: Yes, TLS Bindaddress: 0.0.0.0:5061

7. ViciDial Web Security

ViciDial's web interfaces are the most commonly exploited attack surface. The admin panel (/vicidial/) and agent interface (/agc/) are PHP applications served by Apache, and they have a documented history of critical vulnerabilities. This section covers Apache hardening, HTTPS deployment, IP restrictions, and — critically — the CVE-2024 vulnerabilities that affected every ViciDial installation worldwide.

7.1 Apache Hardening

Disable directory listing and information disclosure:

# Edit Apache main config
# Debian/Ubuntu: /etc/apache2/apache2.conf
# CentOS/openSUSE: /etc/httpd/conf/httpd.conf or /etc/apache2/httpd.conf

vi /etc/apache2/httpd.conf
# Hide Apache version and OS info
ServerTokens Prod
ServerSignature Off

# Disable TRACE method (prevents Cross-Site Tracing attacks)
TraceEnable Off

# Disable directory listing globally
<Directory />
    Options -Indexes -FollowSymLinks
    AllowOverride None
    Require all denied
</Directory>

# Security headers
<IfModule mod_headers.c>
    Header always set X-Content-Type-Options "nosniff"
    Header always set X-Frame-Options "SAMEORIGIN"
    Header always set X-XSS-Protection "1; mode=block"
    Header always set Referrer-Policy "strict-origin-when-cross-origin"
    # Remove PHP version header
    Header unset X-Powered-By
</IfModule>

# Disable ETag (prevents inode-based information disclosure)
FileETag None

# Limit request body size (prevents DoS via large uploads)
LimitRequestBody 10485760

# Timeout settings (prevents slowloris attacks)
Timeout 60
KeepAlive On
MaxKeepAliveRequests 100
KeepAliveTimeout 5

Enable required Apache modules:

# Debian/Ubuntu
a2enmod headers
a2enmod ssl
a2enmod rewrite

# CentOS/openSUSE — modules are typically in httpd.conf
# Ensure these lines are uncommented:
# LoadModule headers_module modules/mod_headers.so
# LoadModule ssl_module modules/mod_ssl.so
# LoadModule rewrite_module modules/mod_rewrite.so

systemctl restart apache2
# or
systemctl restart httpd

7.2 HTTPS with Let's Encrypt

Running ViciDial over plain HTTP means agent credentials, admin passwords, and customer data are transmitted in cleartext. Anyone on the network path can intercept them.

Install Certbot:

# Debian/Ubuntu
apt install certbot python3-certbot-apache

# CentOS/RHEL
yum install epel-release
yum install certbot python3-certbot-apache

# openSUSE
zypper install certbot python3-certbot-apache

Obtain a certificate:

# Ensure your server has a DNS A record pointing to it
# Replace with your actual domain
certbot --apache -d vicidial.yourdomain.com

# If you don't have a domain, use IP-based cert (self-signed)
# Let's Encrypt requires a domain — for IP-only servers, use self-signed:
openssl req -x509 -nodes -days 365 -newkey rsa:4096 \
    -keyout /etc/ssl/private/vicidial.key \
    -out /etc/ssl/certs/vicidial.crt \
    -subj "/CN=YOUR_SERVER_IP/O=ViciDial/C=GB"

Configure Apache SSL VirtualHost:

<VirtualHost *:443>
    ServerName vicidial.yourdomain.com
    DocumentRoot /var/www/html

    SSLEngine on
    SSLCertificateFile /etc/letsencrypt/live/vicidial.yourdomain.com/fullchain.pem
    SSLCertificateKeyFile /etc/letsencrypt/live/vicidial.yourdomain.com/privkey.pem

    # Strong TLS configuration
    SSLProtocol all -SSLv2 -SSLv3 -TLSv1 -TLSv1.1
    SSLCipherSuite ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384
    SSLHonorCipherOrder on
    SSLCompression off
    SSLSessionTickets off

    # HSTS (only enable after confirming HTTPS works)
    Header always set Strict-Transport-Security "max-age=63072000"
</VirtualHost>

# Redirect HTTP to HTTPS
<VirtualHost *:80>
    ServerName vicidial.yourdomain.com
    Redirect permanent / https://vicidial.yourdomain.com/
</VirtualHost>

Auto-renewal:

# Test renewal
certbot renew --dry-run

# Certbot automatically installs a renewal cron/timer
# Verify:
systemctl list-timers | grep certbot
# or
cat /etc/cron.d/certbot

7.3 Admin Panel IP Restrictions

The admin panel (/vicidial/) should only be accessible from trusted IP addresses. This single measure prevents the majority of web-based attacks.

Add to your Apache configuration:

# Restrict ViciDial admin panel to specific IPs
<Directory "/var/www/html/vicidial">
    # Allow from office and VPN
    Require ip YOUR_OFFICE_IP
    Require ip 10.0.0.0/8
    Require ip 127.0.0.1

    # Block everyone else
    # (implicit deny with Require directives in Apache 2.4+)
</Directory>

# Agent panel — can be more permissive but still restrict if possible
<Directory "/var/www/html/agc">
    # If agents only connect via VPN:
    Require ip 10.0.0.0/8
    Require ip YOUR_OFFICE_IP

    # If agents connect from various locations, leave open
    # but ensure HTTPS is enforced
    # Require all granted
</Directory>

# ALWAYS restrict the API
<Directory "/var/www/html/vicidial">
    <Files "non_agent_api.php">
        Require ip YOUR_API_CLIENT_IP
        Require ip 127.0.0.1
    </Files>
    <Files "agent_api.php">
        Require ip YOUR_API_CLIENT_IP
        Require ip 127.0.0.1
    </Files>
</Directory>

# Block access to recordings directory via web
<Directory "/var/spool/asterisk/monitor">
    Require all denied
</Directory>

# If recordings are served from a web-accessible path, restrict:
<Directory "/var/www/html/RECORDINGS">
    # Only allow from admin IPs
    Require ip YOUR_OFFICE_IP
    Require ip 10.0.0.0/8
</Directory>
# Test config and restart
apachectl configtest && systemctl restart apache2

7.4 CVE-2024-8503 and CVE-2024-8504 — Critical ViciDial Vulnerabilities

These two CVEs, published in September 2024, represent the most critical ViciDial vulnerabilities in recent history. If your ViciDial installation has not been patched since August 2024, you are almost certainly vulnerable.

CVE-2024-8503: SQL Injection (CVSS 9.8 — Critical)

What it exploits: The vicidial/login.php file (and related authentication endpoints) fail to properly sanitize user input in the login form. An attacker can inject SQL through the username or password field without any authentication.

Impact:

How to check if you are vulnerable:

# Check your ViciDial build
grep -r "VERSION" /var/www/html/vicidial/admin.php 2>/dev/null | head -5
# or check the admin panel: Admin -> System Settings -> version

# The fix was included in ViciDial SVN revision 3848+
# Check your SVN revision
svn info /usr/share/astguiclient 2>/dev/null | grep "Revision"

Patching steps:

# Option 1: Update ViciDial SVN (recommended if you maintain SVN)
cd /usr/share/astguiclient/trunk
svn update

# Then re-install the web files
cp -r /usr/share/astguiclient/trunk/www/vicidial/* /var/www/html/vicidial/
cp -r /usr/share/astguiclient/trunk/www/agc/* /var/www/html/agc/

# Option 2: Manual patch (if SVN is not configured)
# The vulnerability is in the login processing code
# The fix adds proper escaping/parameterization of user inputs

# Backup first!
cp /var/www/html/vicidial/login.php /var/www/html/vicidial/login.php.bak.$(date +%Y%m%d)

# Apply the specific fix (check ViciDial forums for exact patch)
# The core fix replaces direct variable interpolation with mysql_real_escape_string
# or parameterized queries in the authentication SQL statements

CVE-2024-8504: Remote Code Execution (CVSS 9.8 — Critical)

What it exploits: Once authenticated (even with credentials stolen via CVE-2024-8503), an attacker can execute arbitrary operating system commands through the ViciDial admin interface. The vulnerability exists in admin functions that pass user input to system commands without sanitization.

Impact:

How to check if you are vulnerable:

# Check for the specific vulnerable patterns
# Look for system() or exec() calls with unsanitized input
grep -rn "system\(\$\|exec\(\$\|passthru\(\$\|shell_exec\(\$" \
    /var/www/html/vicidial/admin.php 2>/dev/null | head -20

Patching steps:

The fix is included in the same SVN update as CVE-2024-8503. If you cannot update SVN:

# Temporary mitigation: restrict admin panel access by IP (Section 7.3)
# This prevents exploitation even if the code is vulnerable

# In Apache config:
<Directory "/var/www/html/vicidial">
    Require ip YOUR_TRUSTED_IPS_ONLY
</Directory>

# Also ensure the admin panel is behind HTTPS
# and fail2ban is monitoring login attempts

Combined Defense Against Both CVEs

The strongest defense is layered:

  1. Patch your ViciDial to SVN revision 3848+ (fixes the root cause)
  2. Restrict admin panel by IP (prevents exploitation even if unpatched)
  3. Run fail2ban on web access logs (detects brute force and SQLi attempts)
  4. Monitor admin login logs for unusual activity
  5. Ensure Apache does not run as root (limits blast radius of RCE)
# Verify Apache user
ps aux | grep apache | grep -v grep
# Should show: www-data, apache, or wwwrun — NOT root

# If Apache is running as root, fix it:
# In httpd.conf:
# User wwwrun
# Group www

7.5 API Security

ViciDial's non-agent API (non_agent_api.php) and agent API (agent_api.php) are powerful interfaces that can control campaigns, agents, and calls. They must be secured.

# The API uses IP-based authentication configured in ViciDial admin
# Admin -> System Settings -> Admin Interface
# Set: API Allowed IPs

# Additionally, restrict at the Apache level (defense in depth):

Recommended API configuration in ViciDial admin:

Setting Recommended Value
API Allowed IPs Comma-separated list of trusted IPs only
API User Dedicated API user (not admin account)
API Permission Level Minimum required (1-9, where 9 is full access)

7.6 Session Security Improvements

# Edit PHP configuration to harden sessions
vi /etc/php.ini    # or /etc/php/8.x/apache2/php.ini
; Session security
session.cookie_httponly = 1        ; Prevent JavaScript access to session cookies
session.cookie_secure = 1         ; Only send cookies over HTTPS (enable after HTTPS setup)
session.use_strict_mode = 1       ; Reject uninitialized session IDs
session.use_only_cookies = 1      ; Prevent session fixation via URL
session.cookie_samesite = "Strict" ; CSRF protection

; Disable dangerous PHP features
expose_php = Off                  ; Hide PHP version in headers
allow_url_include = Off           ; Prevent remote file inclusion
display_errors = Off              ; Never show errors to users in production
log_errors = On                   ; Log errors to file instead
error_log = /var/log/php_errors.log

; Upload limits (prevent resource exhaustion)
upload_max_filesize = 10M
post_max_size = 10M
max_execution_time = 60
max_input_time = 60
memory_limit = 256M

; Disable dangerous functions
disable_functions = exec,passthru,shell_exec,system,proc_open,popen,parse_ini_file,show_source
; WARNING: ViciDial uses some of these functions!
; Only disable functions you have verified ViciDial does not use.
; Test thoroughly in a staging environment.

Warning about disable_functions: ViciDial's admin panel uses exec() and system() for legitimate purposes (server status, process management). Disabling these will break admin functionality. Only apply disable_functions if you have tested which functions ViciDial requires and excluded them from the list. The Apache IP restriction from Section 7.3 is a more practical mitigation.


8. MariaDB Security

MariaDB holds everything: agent credentials, customer phone numbers, call recordings metadata, campaign configurations, and CDRs. A database breach means total compromise of your operation.

8.1 Initial Hardening with mysql_secure_installation

If you have not already run this, do it now:

mysql_secure_installation

Answer the prompts:

Set root password? [Y/n] Y
# Enter a strong password (32+ characters)
Remove anonymous users? [Y/n] Y
Disallow root login remotely? [Y/n] Y
Remove test database and access? [Y/n] Y
Reload privilege tables? [Y/n] Y

8.2 Per-User Permissions

The principle of least privilege is critical for database security. Create separate users for each purpose with only the permissions they need.

-- Connect as root
mysql -u root -p

-- 1. ViciDial application user (used by cron scripts and web interface)
-- Needs full access to the ViciDial database, but NOT to other databases
CREATE USER 'cron'@'localhost' IDENTIFIED BY 'STRONG_CRON_PASSWORD_HERE';
GRANT ALL PRIVILEGES ON asterisk.* TO 'cron'@'localhost';
-- Only if using multi-server:
-- GRANT ALL PRIVILEGES ON asterisk.* TO 'cron'@'CLUSTER_NODE_IP' IDENTIFIED BY 'STRONG_CRON_PASSWORD_HERE';

-- 2. Read-only reporting user (for dashboards, Grafana, external tools)
CREATE USER 'grafana_ro'@'localhost' IDENTIFIED BY 'STRONG_GRAFANA_PASSWORD';
GRANT SELECT ON asterisk.* TO 'grafana_ro'@'localhost';
-- Allow from monitoring server if needed:
GRANT SELECT ON asterisk.* TO 'grafana_ro'@'MONITORING_SERVER_IP' IDENTIFIED BY 'STRONG_GRAFANA_PASSWORD';

-- 3. Replication user (if using replica for reporting)
CREATE USER 'repl_user'@'REPLICA_IP' IDENTIFIED BY 'STRONG_REPL_PASSWORD';
GRANT REPLICATION SLAVE ON *.* TO 'repl_user'@'REPLICA_IP';

-- 4. Backup user (for mysqldump)
CREATE USER 'backup'@'localhost' IDENTIFIED BY 'STRONG_BACKUP_PASSWORD';
GRANT SELECT, LOCK TABLES, SHOW VIEW, EVENT, TRIGGER ON asterisk.* TO 'backup'@'localhost';
GRANT RELOAD ON *.* TO 'backup'@'localhost';

-- 5. Audit: Remove any users with overly broad access
-- Check existing users and their permissions
SELECT user, host, authentication_string FROM mysql.user;
SHOW GRANTS FOR 'root'@'localhost';

-- Remove unused users
-- DROP USER 'old_user'@'%';

-- Apply changes
FLUSH PRIVILEGES;

8.3 Network Binding

Control which network interfaces MariaDB listens on:

vi /etc/my.cnf
# or /etc/mysql/my.cnf or /etc/mysql/mariadb.conf.d/50-server.cnf
[mysqld]
# Single server: localhost only
bind-address = 127.0.0.1

# Multi-server cluster: bind to specific interface (not 0.0.0.0)
# bind-address = YOUR_PRIVATE_IP

# Skip name resolution (prevents DNS-based attacks + improves performance)
skip-name-resolve

# Disable symbolic links (prevents symlink-based attacks)
symbolic-links = 0

# Disable LOCAL INFILE (prevents file reading attacks)
local-infile = 0

# Log slow queries (useful for detecting DoS via heavy queries)
slow_query_log = 1
slow_query_log_file = /var/log/mysql/slow.log
long_query_time = 5

8.4 Query Timeouts

Prevent runaway queries from consuming all database resources. This protects against both accidental bad queries and deliberate DoS attacks.

[mysqld]
# Maximum query execution time in seconds (MariaDB 10.1+)
max_statement_time = 30

# Connection timeout
wait_timeout = 600
interactive_timeout = 600

# Max connections (prevent connection flooding)
max_connections = 500
max_connect_errors = 100

# Per-user resource limits
# Applied when creating/altering users:
-- Apply resource limits to the reporting user
ALTER USER 'grafana_ro'@'localhost' WITH
    MAX_QUERIES_PER_HOUR 1000
    MAX_CONNECTIONS_PER_HOUR 100
    MAX_USER_CONNECTIONS 5;

-- Apply stricter limits to the backup user
ALTER USER 'backup'@'localhost' WITH
    MAX_QUERIES_PER_HOUR 100
    MAX_CONNECTIONS_PER_HOUR 10
    MAX_USER_CONNECTIONS 2;

FLUSH PRIVILEGES;

Test query timeout:

-- This should be killed after max_statement_time seconds:
SET STATEMENT max_statement_time=5 FOR
SELECT SLEEP(30);
-- Should return: ERROR 1969 (70100): Query execution was interrupted (max_statement_time exceeded)

8.5 Audit Logging (Optional)

For environments requiring database audit trails:

[mysqld]
# MariaDB Audit Plugin (built-in since 10.0)
plugin_load_add = server_audit
server_audit_events = CONNECT,QUERY_DDL,QUERY_DCL
server_audit_logging = ON
server_audit_file_path = /var/log/mysql/audit.log
server_audit_file_rotate_size = 104857600
server_audit_file_rotations = 10
# Exclude noisy SELECT queries:
server_audit_excl_users = 'cron'

9. Toll Fraud Prevention

Toll fraud is the single most expensive security incident for a VoIP operation. Attackers compromise a SIP peer or trunk and route calls to premium-rate international numbers, generating charges of thousands of dollars per hour. This section covers prevention, detection, and response.

9.1 Understanding Toll Fraud

How it happens:

  1. Attacker scans for SIP servers on port 5060 (automated, 24/7)
  2. Attacker brute-forces SIP credentials (peer names and passwords)
  3. Attacker registers as a legitimate SIP peer
  4. Attacker sends INVITE requests to premium-rate numbers (often in Somalia, Cuba, Sierra Leone, Gambia)
  5. Premium-rate numbers are owned by the attacker or an accomplice
  6. Calls are charged to YOUR trunk account
  7. Attacker earns revenue from the premium-rate number
  8. You receive a bill for thousands of dollars

Warning signs:

9.2 Dialplan-Based International Call Restrictions

The most effective toll fraud prevention is simply not allowing calls to international numbers you do not need:

Add to extensions.conf:

; ============================================================
; TOLL FRAUD PREVENTION — Blocked Destinations
; ============================================================

; Block premium-rate international destinations
; These are the most commonly targeted for toll fraud

[toll-fraud-block]
; Premium rate numbers (international)
exten => _900.,1,NoOp(BLOCKED: Premium ${EXTEN})
same => n,Hangup(21)

exten => _0900.,1,NoOp(BLOCKED: Premium ${EXTEN})
same => n,Hangup(21)

; International directory enquiries
exten => _118.,1,NoOp(BLOCKED: Directory ${EXTEN})
same => n,Hangup(21)

; High-fraud-risk country codes (adjust based on your business)
; Somalia +252
exten => _00252.,1,NoOp(BLOCKED: Somalia ${EXTEN})
same => n,Hangup(21)

; Cuba +53
exten => _0053.,1,NoOp(BLOCKED: Cuba ${EXTEN})
same => n,Hangup(21)

; Sierra Leone +232
exten => _00232.,1,NoOp(BLOCKED: Sierra Leone ${EXTEN})
same => n,Hangup(21)

; Gambia +220
exten => _00220.,1,NoOp(BLOCKED: Gambia ${EXTEN})
same => n,Hangup(21)

; Maldives +960
exten => _00960.,1,NoOp(BLOCKED: Maldives ${EXTEN})
same => n,Hangup(21)

; International IRSF destinations (International Revenue Share Fraud)
; Add country codes based on your trunk provider's fraud list

; Include this context in your outbound context:
; include => toll-fraud-block
; Then include your actual allowed destinations AFTER

Example outbound context with fraud protection:

[outbound-vicidial]
; First, check against toll fraud blocklist
include => toll-fraud-block

; Then allow legitimate destinations
; UK domestic
exten => _01XXXXXXXXX,1,Dial(SIP/my_trunk/${EXTEN})
exten => _02XXXXXXXXX,1,Dial(SIP/my_trunk/${EXTEN})
exten => _07XXXXXXXXX,1,Dial(SIP/my_trunk/${EXTEN})

; Allowed international (add only countries you actually call)
exten => _0039.,1,Dial(SIP/my_trunk/${EXTEN})    ; Italy
exten => _0033.,1,Dial(SIP/my_trunk/${EXTEN})    ; France
exten => _0049.,1,Dial(SIP/my_trunk/${EXTEN})    ; Germany

; Block everything else
exten => _X.,1,NoOp(BLOCKED: Unrecognized number ${EXTEN})
same => n,Hangup(21)

9.3 Call Duration Limits

Prevent toll fraud calls from running indefinitely:

In extensions.conf:

; Set absolute timeout on all outbound calls (seconds)
; 3600 = 1 hour maximum call duration
exten => _X.,1,Set(TIMEOUT(absolute)=3600)
same => n,Dial(SIP/my_trunk/${EXTEN},60)
same => n,Hangup()

In sip.conf (per-peer):

[agent_phone]
call-limit = 2    ; Max 2 concurrent calls (1 active + 1 on hold)

In ViciDial admin (System Settings):

Setting Recommended Value Purpose
Max Call Length 3600 Maximum call duration in seconds
Concurrent Call Limit Appropriate for your campaign Prevents mass simultaneous fraudulent calls

9.4 After-Hours Call Blocking

Most toll fraud occurs outside business hours when no one is watching:

; Block outbound calls outside business hours
[time-check]
exten => _X.,1,GotoIfTime(08:00-20:00,mon-fri,*,*?allowed,${EXTEN},1)
same => n,GotoIfTime(09:00-17:00,sat,*,*?allowed,${EXTEN},1)
same => n,NoOp(BLOCKED: After hours call to ${EXTEN})
same => n,Hangup(21)

[allowed]
exten => _X.,1,Dial(SIP/my_trunk/${EXTEN})
same => n,Hangup()

9.5 Monitoring for Unusual Patterns

Daily toll fraud detection script — save as /usr/local/bin/toll-fraud-check.sh:

#!/bin/bash
# Daily toll fraud detection for ViciDial
# Checks for unusual calling patterns in the last 24 hours

DB_USER="report_cron"
DB_PASS="YOUR_DB_PASSWORD"
DB_NAME="asterisk"
ALERT_EMAIL="[email protected]"
ALERT=0

echo "=== Toll Fraud Check — $(date '+%Y-%m-%d %H:%M') ==="

# 1. Check for calls to high-risk country codes
echo ""
echo "--- Calls to High-Risk Countries (last 24h) ---"
FRAUD_CALLS=$(mysql -u"$DB_USER" -p"$DB_PASS" "$DB_NAME" -N -e "
    SELECT phone_number, COUNT(*) as calls, SUM(length_in_sec) as total_sec
    FROM vicidial_log
    WHERE call_date > DATE_SUB(NOW(), INTERVAL 24 HOUR)
    AND (phone_number LIKE '00252%'
         OR phone_number LIKE '0053%'
         OR phone_number LIKE '00232%'
         OR phone_number LIKE '00220%'
         OR phone_number LIKE '00960%'
         OR phone_number LIKE '00682%'
         OR phone_number LIKE '00686%'
         OR phone_number LIKE '00269%')
    GROUP BY phone_number
    ORDER BY calls DESC
    LIMIT 20;
")

if [ -n "$FRAUD_CALLS" ]; then
    echo "WARNING: Calls to high-risk destinations detected!"
    echo "$FRAUD_CALLS"
    ALERT=1
else
    echo "No calls to high-risk destinations."
fi

# 2. Check for unusually long calls (>1 hour)
echo ""
echo "--- Unusually Long Calls (>3600s, last 24h) ---"
LONG_CALLS=$(mysql -u"$DB_USER" -p"$DB_PASS" "$DB_NAME" -N -e "
    SELECT phone_number, user, length_in_sec, call_date
    FROM vicidial_log
    WHERE call_date > DATE_SUB(NOW(), INTERVAL 24 HOUR)
    AND length_in_sec > 3600
    ORDER BY length_in_sec DESC
    LIMIT 20;
")

if [ -n "$LONG_CALLS" ]; then
    echo "WARNING: Unusually long calls detected!"
    echo "$LONG_CALLS"
    ALERT=1
else
    echo "No unusually long calls."
fi

# 3. Check for after-hours call activity
echo ""
echo "--- After-Hours Calls (22:00-06:00, last 24h) ---"
AFTERHOURS=$(mysql -u"$DB_USER" -p"$DB_PASS" "$DB_NAME" -N -e "
    SELECT COUNT(*) as calls, SUM(length_in_sec) as total_sec
    FROM vicidial_log
    WHERE call_date > DATE_SUB(NOW(), INTERVAL 24 HOUR)
    AND (HOUR(call_date) >= 22 OR HOUR(call_date) < 6);
")

echo "After-hours calls: $AFTERHOURS"

# 4. Check for unknown SIP registrations
echo ""
echo "--- Unknown SIP Registrations ---"
asterisk -rx "sip show peers" 2>/dev/null | grep -v "OK\|UNREACHABLE\|Unmonitored\|Name" | head -20

# 5. Send alert if issues found
if [ $ALERT -eq 1 ]; then
    echo ""
    echo "ALERT: Potential toll fraud indicators detected. Review immediately."
    # Uncomment to send email alert:
    # echo "Toll fraud indicators detected on $(hostname). Check /var/log/toll-fraud-check.log" | \
    #     mail -s "TOLL FRAUD ALERT - $(hostname)" "$ALERT_EMAIL"
fi
chmod +x /usr/local/bin/toll-fraud-check.sh

# Add to crontab — run every 6 hours
(crontab -l 2>/dev/null; echo "0 */6 * * * /usr/local/bin/toll-fraud-check.sh >> /var/log/toll-fraud-check.log 2>&1") | crontab -

9.6 SIP Trunk Provider Security

Work with your SIP trunk provider to add server-side protections:

Protection Description Ask Your Provider
Fraud monitoring Real-time call pattern analysis "Do you offer fraud detection alerts?"
Daily spend cap Maximum daily spend limit "Can you set a daily limit of $X?"
Country blocking Block specific destination countries "Can you block calls to [country list]?"
IP allowlisting Only accept traffic from your server IPs "Can you restrict our trunk to these source IPs?"
Concurrent call limit Max simultaneous calls through the trunk "Set our concurrent call limit to X channels"
After-hours blocking Block calls outside business hours "Can you block outbound calls between 22:00-06:00?"

Pro tip: Most trunk providers will set a daily spend cap at no charge. This is your last line of defense — even if every other measure fails, the spend cap limits your financial exposure. Set it to 150% of your highest legitimate daily spend.


10. Monitoring & Alerting

Security without monitoring is just hope. You need visibility into what is happening on your server in real time and the ability to detect anomalies before they become incidents.

10.1 Log Monitoring with Loki and Promtail

Centralized log aggregation lets you query and alert on security events across all your ViciDial servers from a single interface.

Install Promtail on the ViciDial server:

# Download latest Promtail
PROMTAIL_VERSION="2.9.4"
curl -LO "https://github.com/grafana/loki/releases/download/v${PROMTAIL_VERSION}/promtail-linux-amd64.zip"
unzip promtail-linux-amd64.zip
mv promtail-linux-amd64 /usr/local/bin/promtail
chmod +x /usr/local/bin/promtail

Configure Promtail — create /etc/promtail/config.yml:

server:
  http_listen_port: 9080
  grpc_listen_port: 0

positions:
  filename: /var/lib/promtail/positions.yaml

clients:
  - url: http://YOUR_LOKI_SERVER:3100/loki/api/v1/push

scrape_configs:
  # Asterisk security logs — SIP auth failures, toll fraud indicators
  - job_name: asterisk
    static_configs:
      - targets:
          - localhost
        labels:
          job: asterisk
          host: YOUR_SERVER_HOSTNAME
          __path__: /var/log/asterisk/messages

  # Apache access logs — web attacks, brute force
  - job_name: apache_access
    static_configs:
      - targets:
          - localhost
        labels:
          job: apache
          type: access
          host: YOUR_SERVER_HOSTNAME
          __path__: /var/log/apache2/access_log

  # Apache error logs
  - job_name: apache_error
    static_configs:
      - targets:
          - localhost
        labels:
          job: apache
          type: error
          host: YOUR_SERVER_HOSTNAME
          __path__: /var/log/apache2/error_log

  # fail2ban logs — ban events
  - job_name: fail2ban
    static_configs:
      - targets:
          - localhost
        labels:
          job: fail2ban
          host: YOUR_SERVER_HOSTNAME
          __path__: /var/log/fail2ban.log

  # SSH auth logs
  - job_name: auth
    static_configs:
      - targets:
          - localhost
        labels:
          job: auth
          host: YOUR_SERVER_HOSTNAME
          __path__: /var/log/auth.log

  # MySQL slow query log
  - job_name: mysql_slow
    static_configs:
      - targets:
          - localhost
        labels:
          job: mysql
          type: slow
          host: YOUR_SERVER_HOSTNAME
          __path__: /var/log/mysql/slow.log

Create systemd service/etc/systemd/system/promtail.service:

[Unit]
Description=Promtail Log Agent
After=network.target

[Service]
Type=simple
User=root
ExecStart=/usr/local/bin/promtail -config.file=/etc/promtail/config.yml
Restart=always
RestartSec=10

[Install]
WantedBy=multi-user.target
mkdir -p /etc/promtail /var/lib/promtail
systemctl daemon-reload
systemctl enable promtail
systemctl start promtail

10.2 Prometheus Alerts for Security Events

If you run Prometheus for monitoring, add these alert rules for security-relevant events.

Create /etc/prometheus/rules/vicidial-security.yml:

groups:
  - name: vicidial_security
    interval: 60s
    rules:
      # Alert on high fail2ban ban rate
      - alert: HighFail2banBanRate
        expr: increase(fail2ban_banned_total[1h]) > 50
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "High fail2ban ban rate on {{ $labels.instance }}"
          description: "More than 50 IPs banned in the last hour. Possible active attack."

      # Alert on unusual SIP registration count
      - alert: UnusualSIPRegistrations
        expr: asterisk_sip_peers_online > (asterisk_sip_peers_online offset 1h) * 1.5
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "Unusual SIP registration spike on {{ $labels.instance }}"
          description: "SIP peer count jumped 50% in the last hour."

      # Alert if fail2ban is not running
      - alert: Fail2banDown
        expr: up{job="fail2ban"} == 0 or absent(up{job="fail2ban"})
        for: 5m
        labels:
          severity: critical
        annotations:
          summary: "fail2ban is down on {{ $labels.instance }}"
          description: "fail2ban is not running. Server is unprotected against brute force."

      # Alert on disk usage (recordings can fill disk)
      - alert: DiskSpaceLow
        expr: (node_filesystem_avail_bytes{mountpoint="/"} / node_filesystem_size_bytes{mountpoint="/"}) < 0.1
        for: 10m
        labels:
          severity: critical
        annotations:
          summary: "Low disk space on {{ $labels.instance }}"
          description: "Less than 10% disk space remaining."

      # Alert on high concurrent calls (possible toll fraud)
      - alert: HighConcurrentCalls
        expr: asterisk_active_calls > 100
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "Unusually high concurrent calls on {{ $labels.instance }}"
          description: "{{ $value }} active calls. Check for toll fraud."

10.3 Daily Security Audit Script

This comprehensive script checks for common security issues and generates a daily report.

Save as /usr/local/bin/daily-security-audit.sh:

#!/bin/bash
# =============================================================
# ViciDial Daily Security Audit Script
# Run daily via cron — generates a security status report
# =============================================================

REPORT_FILE="/var/log/security-audit-$(date +%Y%m%d).log"
ALERT=0

{
echo "============================================================"
echo "  DAILY SECURITY AUDIT — $(hostname)"
echo "  $(date '+%Y-%m-%d %H:%M:%S %Z')"
echo "============================================================"
echo ""

# 1. OPEN PORTS CHECK
echo "=== 1. Open Ports ==="
ss -tlnp | grep LISTEN | awk '{print $4, $6}' | sort
echo ""

# Check for unexpected ports
UNEXPECTED=$(ss -tlnp | grep LISTEN | grep -v -E ':(9322|80|443|5060|5061|5038|3306|4569|10000|127\.0\.0\.1)' | grep '0.0.0.0')
if [ -n "$UNEXPECTED" ]; then
    echo "WARNING: Unexpected ports listening on all interfaces:"
    echo "$UNEXPECTED"
    ALERT=1
fi
echo ""

# 2. FAILED SSH LOGINS (last 24h)
echo "=== 2. Failed SSH Logins (Last 24h) ==="
if [ -f /var/log/auth.log ]; then
    SSH_FAILS=$(grep "Failed password\|Failed publickey" /var/log/auth.log | \
        grep "$(date '+%b %d')\|$(date -d yesterday '+%b %d')" | wc -l)
    echo "Failed SSH attempts: $SSH_FAILS"
    if [ "$SSH_FAILS" -gt 100 ]; then
        echo "WARNING: High number of SSH failures!"
        ALERT=1
    fi
    echo "Top source IPs:"
    grep "Failed password\|Failed publickey" /var/log/auth.log | \
        grep -oE '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' | sort | uniq -c | sort -rn | head -10
elif [ -f /var/log/secure ]; then
    SSH_FAILS=$(grep "Failed password\|Failed publickey" /var/log/secure | wc -l)
    echo "Failed SSH attempts: $SSH_FAILS"
fi
echo ""

# 3. FAIL2BAN STATUS
echo "=== 3. fail2ban Status ==="
if systemctl is-active --quiet fail2ban; then
    echo "Status: RUNNING"
    fail2ban-client status 2>/dev/null | grep -E "Jail list|Number of jail"
    echo ""
    echo "Currently banned IPs:"
    for jail in $(fail2ban-client status 2>/dev/null | grep "Jail list" | sed 's/.*://;s/,/ /g'); do
        banned=$(fail2ban-client status "$jail" 2>/dev/null | grep "Currently banned" | awk '{print $NF}')
        total=$(fail2ban-client status "$jail" 2>/dev/null | grep "Total banned" | awk '{print $NF}')
        echo "  $jail: $banned current / $total total"
    done
else
    echo "CRITICAL: fail2ban is NOT running!"
    ALERT=1
fi
echo ""

# 4. SIP PEER STATUS
echo "=== 4. SIP Peer Check ==="
if command -v asterisk &>/dev/null; then
    TOTAL_PEERS=$(asterisk -rx "sip show peers" 2>/dev/null | tail -1)
    echo "Peer summary: $TOTAL_PEERS"

    # Check for peers registered from unexpected IPs
    echo "Registered peers from external IPs:"
    asterisk -rx "sip show peers" 2>/dev/null | grep -v "127.0.0.1\|10\.\|172\.\|192\.168\.\|Unmonitored\|UNREACHABLE\|Name" | head -20
fi
echo ""

# 5. DISK USAGE
echo "=== 5. Disk Usage ==="
df -h / /var/spool/asterisk/monitor 2>/dev/null | grep -v "^Filesystem"
DISK_PCT=$(df / | tail -1 | awk '{print $5}' | tr -d '%')
if [ "$DISK_PCT" -gt 85 ]; then
    echo "WARNING: Root filesystem is ${DISK_PCT}% full!"
    ALERT=1
fi
echo ""

# 6. NEW/MODIFIED SYSTEM USERS (last 24h)
echo "=== 6. User Account Changes ==="
NEW_USERS=$(find /etc/passwd -mtime -1 2>/dev/null)
if [ -n "$NEW_USERS" ]; then
    echo "WARNING: /etc/passwd was modified in the last 24 hours!"
    ALERT=1
else
    echo "No user account changes detected."
fi

# Check for unauthorized sudoers changes
SUDO_CHANGES=$(find /etc/sudoers /etc/sudoers.d/ -mtime -1 2>/dev/null)
if [ -n "$SUDO_CHANGES" ]; then
    echo "WARNING: sudoers files modified in the last 24 hours!"
    echo "$SUDO_CHANGES"
    ALERT=1
fi
echo ""

# 7. CRONTAB CHANGES
echo "=== 7. Crontab Check ==="
CRON_CHANGES=$(find /var/spool/cron/ /etc/cron.d/ -mtime -1 2>/dev/null)
if [ -n "$CRON_CHANGES" ]; then
    echo "Modified cron files (last 24h):"
    echo "$CRON_CHANGES"
else
    echo "No crontab changes in the last 24 hours."
fi
echo ""

# 8. ASTERISK LOG ERRORS
echo "=== 8. Asterisk Security Events (Last 24h) ==="
if [ -f /var/log/asterisk/messages ]; then
    SECURITY_EVENTS=$(grep -c "SecurityEvent\|failed to authenticate\|Registration.*failed" \
        /var/log/asterisk/messages 2>/dev/null)
    echo "Security events in Asterisk log: ${SECURITY_EVENTS:-0}"

    if [ "${SECURITY_EVENTS:-0}" -gt 100 ]; then
        echo "WARNING: High number of Asterisk security events!"
        echo "Top attacking IPs:"
        grep -oE "RemoteAddress.*[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+" /var/log/asterisk/messages | \
            grep -oE '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' | sort | uniq -c | sort -rn | head -10
        ALERT=1
    fi
fi
echo ""

# 9. APACHE SECURITY
echo "=== 9. Apache Security Events ==="
if [ -f /var/log/apache2/access_log ] || [ -f /var/log/httpd/access_log ]; then
    ACCESS_LOG=$(ls /var/log/apache2/access_log /var/log/httpd/access_log 2>/dev/null | head -1)
    VULN_SCANS=$(grep -c -E "\.\.\/|etc\/passwd|wp-login|phpmyadmin|\.env|config\.php|shell\.php" "$ACCESS_LOG" 2>/dev/null)
    echo "Vulnerability scan attempts: ${VULN_SCANS:-0}"

    HTTP_401=$(grep -c " 401 " "$ACCESS_LOG" 2>/dev/null)
    echo "HTTP 401 (Unauthorized): ${HTTP_401:-0}"

    HTTP_403=$(grep -c " 403 " "$ACCESS_LOG" 2>/dev/null)
    echo "HTTP 403 (Forbidden): ${HTTP_403:-0}"
fi
echo ""

# 10. MYSQL/MARIADB
echo "=== 10. Database Security ==="
if command -v mysql &>/dev/null; then
    # Check if MySQL is listening on all interfaces
    MYSQL_BIND=$(ss -tlnp | grep 3306 | grep "0.0.0.0")
    if [ -n "$MYSQL_BIND" ]; then
        echo "WARNING: MySQL is listening on ALL interfaces (0.0.0.0:3306)!"
        ALERT=1
    else
        echo "MySQL binding: OK (localhost only or specific IP)"
    fi

    # Check for users without passwords
    NO_PASS=$(mysql -u root -e "SELECT user, host FROM mysql.user WHERE authentication_string='' OR authentication_string IS NULL;" 2>/dev/null)
    if [ -n "$NO_PASS" ]; then
        echo "WARNING: Users without passwords found:"
        echo "$NO_PASS"
        ALERT=1
    fi
fi
echo ""

# SUMMARY
echo "============================================================"
if [ $ALERT -eq 1 ]; then
    echo "  RESULT: WARNINGS FOUND — Review items marked WARNING above"
else
    echo "  RESULT: ALL CHECKS PASSED"
fi
echo "============================================================"

} | tee "$REPORT_FILE"

# Clean up old audit reports (keep 30 days)
find /var/log/ -name "security-audit-*.log" -mtime +30 -delete 2>/dev/null
chmod +x /usr/local/bin/daily-security-audit.sh

# Add to crontab — run daily at 06:00
(crontab -l 2>/dev/null; echo "0 6 * * * /usr/local/bin/daily-security-audit.sh > /dev/null 2>&1") | crontab -

11. Backup & Recovery

Security is not just about preventing attacks — it is about recovering from them. A compromised server with good backups can be rebuilt in hours. Without backups, you may lose everything.

11.1 What to Backup

Component Path Priority Frequency
Asterisk configs /etc/asterisk/ Critical Daily
ViciDial database asterisk database Critical Daily (full), hourly (binlog)
ViciDial web files /var/www/html/vicidial/, /var/www/html/agc/ High Weekly
Call recordings /var/spool/asterisk/monitor/ High Daily
Crontabs /var/spool/cron/ Medium Daily
Custom scripts /usr/local/bin/, /usr/share/astguiclient/ High Daily
AGI scripts /var/lib/asterisk/agi-bin/ High Daily
SSL certificates /etc/letsencrypt/ or /etc/asterisk/keys/ High Weekly
fail2ban config /etc/fail2ban/ Medium Weekly
Firewall rules /etc/iptables/, /etc/ufw/ Medium Weekly
System configs /etc/ssh/sshd_config, /etc/my.cnf Medium Weekly

11.2 Automated SFTP Backup Script

This production-tested script backs up all critical components and uploads to a remote storage box via SFTP.

Save as /usr/local/bin/vicidial-backup.sh:

#!/bin/bash
# =============================================================
# ViciDial Automated Backup Script
# Backs up configs, database, and recordings to remote storage
# =============================================================

# Configuration
BACKUP_DIR="/root/backups"
REMOTE_HOST="YOUR_BACKUP_SERVER"
REMOTE_USER="YOUR_SFTP_USER"
REMOTE_PASS="YOUR_SFTP_PASSWORD"
REMOTE_DIR="/backups/$(hostname)"
DB_USER="backup"
DB_PASS="YOUR_BACKUP_DB_PASSWORD"
DB_NAME="asterisk"
RETENTION_DAYS=30
DATE=$(date +%Y%m%d_%H%M)
LOG="/var/log/backup.log"

# Create local backup directory
mkdir -p "$BACKUP_DIR"

echo "$(date): Starting backup" >> "$LOG"

# 1. Database backup (compressed)
echo "$(date): Backing up database..." >> "$LOG"
mysqldump -u"$DB_USER" -p"$DB_PASS" "$DB_NAME" \
    --single-transaction \
    --routines \
    --triggers \
    --events \
    --quick \
    --lock-tables=false \
    | gzip > "$BACKUP_DIR/db_${DB_NAME}_${DATE}.sql.gz"

if [ $? -eq 0 ]; then
    echo "$(date): Database backup OK ($(du -sh "$BACKUP_DIR/db_${DB_NAME}_${DATE}.sql.gz" | awk '{print $1}'))" >> "$LOG"
else
    echo "$(date): ERROR: Database backup failed!" >> "$LOG"
fi

# 2. Asterisk configuration
echo "$(date): Backing up Asterisk configs..." >> "$LOG"
tar czf "$BACKUP_DIR/asterisk_config_${DATE}.tar.gz" \
    /etc/asterisk/ \
    2>/dev/null

# 3. ViciDial web files
echo "$(date): Backing up ViciDial web files..." >> "$LOG"
tar czf "$BACKUP_DIR/vicidial_web_${DATE}.tar.gz" \
    /var/www/html/vicidial/ \
    /var/www/html/agc/ \
    2>/dev/null

# 4. Custom scripts and AGI
echo "$(date): Backing up scripts..." >> "$LOG"
tar czf "$BACKUP_DIR/scripts_${DATE}.tar.gz" \
    /usr/share/astguiclient/ \
    /var/lib/asterisk/agi-bin/ \
    /usr/local/bin/*.sh \
    2>/dev/null

# 5. Crontabs
echo "$(date): Backing up crontabs..." >> "$LOG"
crontab -l > "$BACKUP_DIR/crontab_root_${DATE}.txt" 2>/dev/null

# 6. System configurations
echo "$(date): Backing up system configs..." >> "$LOG"
tar czf "$BACKUP_DIR/system_config_${DATE}.tar.gz" \
    /etc/ssh/sshd_config \
    /etc/my.cnf \
    /etc/fail2ban/ \
    /etc/apache2/ \
    /etc/httpd/ \
    2>/dev/null

# 7. Upload to remote storage via SFTP
echo "$(date): Uploading to remote storage..." >> "$LOG"
sshpass -p "$REMOTE_PASS" sftp -oBatchMode=no -oStrictHostKeyChecking=no \
    "$REMOTE_USER@$REMOTE_HOST" <<SFTP_EOF
-mkdir $REMOTE_DIR
cd $REMOTE_DIR
put $BACKUP_DIR/db_${DB_NAME}_${DATE}.sql.gz
put $BACKUP_DIR/asterisk_config_${DATE}.tar.gz
put $BACKUP_DIR/vicidial_web_${DATE}.tar.gz
put $BACKUP_DIR/scripts_${DATE}.tar.gz
put $BACKUP_DIR/crontab_root_${DATE}.txt
put $BACKUP_DIR/system_config_${DATE}.tar.gz
bye
SFTP_EOF

if [ $? -eq 0 ]; then
    echo "$(date): Remote upload OK" >> "$LOG"
else
    echo "$(date): ERROR: Remote upload failed!" >> "$LOG"
fi

# 8. Clean up old local backups
echo "$(date): Cleaning up old backups (>${RETENTION_DAYS} days)..." >> "$LOG"
find "$BACKUP_DIR" -name "*.tar.gz" -mtime +$RETENTION_DAYS -delete 2>/dev/null
find "$BACKUP_DIR" -name "*.sql.gz" -mtime +$RETENTION_DAYS -delete 2>/dev/null
find "$BACKUP_DIR" -name "*.txt" -mtime +$RETENTION_DAYS -delete 2>/dev/null

# 9. Verify backup sizes (detect empty/failed backups)
echo "$(date): Backup sizes:" >> "$LOG"
ls -lh "$BACKUP_DIR"/*${DATE}* >> "$LOG" 2>/dev/null

for f in "$BACKUP_DIR"/*${DATE}*; do
    SIZE=$(stat -f%z "$f" 2>/dev/null || stat -c%s "$f" 2>/dev/null)
    if [ "${SIZE:-0}" -lt 1000 ]; then
        echo "$(date): WARNING: $f is suspiciously small (${SIZE} bytes)" >> "$LOG"
    fi
done

echo "$(date): Backup complete" >> "$LOG"
echo "---" >> "$LOG"
chmod +x /usr/local/bin/vicidial-backup.sh

# Run daily at 02:00 (low traffic period)
(crontab -l 2>/dev/null; echo "0 2 * * * /usr/local/bin/vicidial-backup.sh") | crontab -

11.3 Recording Backup and Cleanup

Recordings consume the most disk space. Implement a separate policy:

# Recording cleanup script — /usr/local/bin/recording-cleanup.sh
#!/bin/bash
# Clean up old recordings after a configurable retention period

RECORDING_DIR="/var/spool/asterisk/monitor"
RETENTION_DAYS=90
LOG="/var/log/recording-cleanup.log"

echo "$(date): Starting recording cleanup (>${RETENTION_DAYS} days)" >> "$LOG"

# Count files before cleanup
BEFORE=$(find "$RECORDING_DIR" -name "*.wav" -o -name "*.mp3" -o -name "*.gsm" 2>/dev/null | wc -l)

# Delete old recordings
find "$RECORDING_DIR" -type f \( -name "*.wav" -o -name "*.mp3" -o -name "*.gsm" \) \
    -mtime +$RETENTION_DAYS -delete 2>/dev/null

# Remove empty date directories
find "$RECORDING_DIR" -type d -empty -delete 2>/dev/null

# Count files after cleanup
AFTER=$(find "$RECORDING_DIR" -name "*.wav" -o -name "*.mp3" -o -name "*.gsm" 2>/dev/null | wc -l)
DELETED=$((BEFORE - AFTER))

echo "$(date): Cleanup complete. Deleted $DELETED files. Remaining: $AFTER" >> "$LOG"
echo "$(date): Disk usage: $(du -sh "$RECORDING_DIR" 2>/dev/null | awk '{print $1}')" >> "$LOG"
chmod +x /usr/local/bin/recording-cleanup.sh

# Run daily at 03:00
(crontab -l 2>/dev/null; echo "0 3 * * * /usr/local/bin/recording-cleanup.sh") | crontab -

11.4 Disaster Recovery Checklist

When the worst happens — server compromised, hardware failure, or data corruption — follow this sequence:

Step Action Details
1 Isolate Disconnect from network or block all traffic except your admin IP
2 Assess Determine scope: what was accessed, what was modified, how did they get in
3 Preserve evidence Snapshot disk, copy logs before they are overwritten
4 Rebuild or restore Clean OS install (preferred) or restore from last known good backup
5 Restore database gunzip < db_backup.sql.gz | mysql -u root -p asterisk
6 Restore configs Extract Asterisk, Apache, fail2ban configs from backup tarballs
7 Change ALL credentials SIP passwords, AMI passwords, MySQL passwords, SSH keys, admin passwords
8 Apply hardening Apply all measures from this guide before bringing back online
9 Monitor closely Watch logs intensively for 72 hours after restoration
10 Post-incident review Document what happened, how, and what to improve

Quick restore commands:

# Restore database
gunzip < /root/backups/db_asterisk_YYYYMMDD_HHMM.sql.gz | mysql -u root -p asterisk

# Restore Asterisk configs
cd /
tar xzf /root/backups/asterisk_config_YYYYMMDD_HHMM.tar.gz

# Restore ViciDial web files
cd /
tar xzf /root/backups/vicidial_web_YYYYMMDD_HHMM.tar.gz

# Restore scripts
cd /
tar xzf /root/backups/scripts_YYYYMMDD_HHMM.tar.gz

# Restore crontab
crontab /root/backups/crontab_root_YYYYMMDD_HHMM.txt

# Restart all services
systemctl restart asterisk
systemctl restart apache2
systemctl restart mysql
systemctl restart fail2ban

12. Security Audit Checklist

Use this checklist monthly to verify your security posture. The automated script in Section 10.3 covers most of these, but manual verification catches things automation misses.

Monthly Security Audit Checklist

Date: ___________  Auditor: ___________  Server: ___________

SSH & ACCESS CONTROL
[ ] SSH key-only auth is enforced (PasswordAuthentication no)
[ ] SSH is on a non-standard port
[ ] Only authorized keys in ~/.ssh/authorized_keys
[ ] No unexpected users in /etc/passwd
[ ] No unexpected entries in /etc/sudoers or /etc/sudoers.d/
[ ] AllowUsers directive in sshd_config is correct

FIREWALL
[ ] Firewall is active (ufw status / iptables -L)
[ ] No ACCEPT ALL rules in iptables
[ ] SIP (5060) restricted to trunk provider IPs only
[ ] AMI (5038) only on localhost
[ ] MySQL (3306) only on localhost (or cluster IPs)
[ ] No unexpected ports open (ss -tlnp)

FAIL2BAN
[ ] fail2ban service is running
[ ] All configured jails are active
[ ] Whitelist (ignoreip) is correct
[ ] Ban counts are reasonable (not zero — that means it is not working)
[ ] Permanent blocklist is being maintained

ASTERISK
[ ] alwaysauthreject = yes in sip.conf
[ ] allowguest = no in sip.conf
[ ] No SIP peers with weak passwords (<16 chars)
[ ] No SIP peers without ACLs (permit/deny)
[ ] AMI bindaddr = 127.0.0.1
[ ] AMI passwords are strong (>32 chars)
[ ] Unused modules are disabled

WEB SECURITY
[ ] Apache ServerTokens Prod / ServerSignature Off
[ ] HTTPS is working (certificate not expired)
[ ] Admin panel (/vicidial/) has IP restrictions
[ ] Recording directory is not web-accessible
[ ] ViciDial is patched against CVE-2024-8503/8504
[ ] PHP expose_php = Off

DATABASE
[ ] mysql_secure_installation has been run
[ ] No users without passwords
[ ] No users with ALL PRIVILEGES except root@localhost
[ ] bind-address is not 0.0.0.0 (unless multi-server)
[ ] max_statement_time is set
[ ] Reporting users have SELECT only

TOLL FRAUD
[ ] International call restrictions in dialplan
[ ] Trunk provider has daily spend cap
[ ] After-hours monitoring is active
[ ] No unusual patterns in recent CDRs

BACKUPS
[ ] Backup script is running (check last run in logs)
[ ] Remote backups are completing (verify on storage server)
[ ] Backup files are not empty (check sizes)
[ ] Test restore has been performed this quarter

MONITORING
[ ] Promtail/log agent is running and sending logs
[ ] Grafana dashboards show data
[ ] Alert rules are firing correctly (test with a known trigger)
[ ] Daily audit script is running

Automated Audit Script

Save as /usr/local/bin/security-audit-auto.sh for automated monthly checks:

#!/bin/bash
# =============================================================
# Automated Security Audit — ViciDial
# Outputs PASS/FAIL for each check
# =============================================================

PASS=0
FAIL=0
WARN=0

check() {
    local name="$1"
    local result="$2"
    if [ "$result" = "PASS" ]; then
        echo "  [PASS] $name"
        PASS=$((PASS + 1))
    elif [ "$result" = "WARN" ]; then
        echo "  [WARN] $name"
        WARN=$((WARN + 1))
    else
        echo "  [FAIL] $name"
        FAIL=$((FAIL + 1))
    fi
}

echo "============================================================"
echo "  AUTOMATED SECURITY AUDIT — $(hostname)"
echo "  $(date '+%Y-%m-%d %H:%M:%S')"
echo "============================================================"
echo ""

# SSH checks
echo "--- SSH & Access Control ---"
PASS_AUTH=$(grep -c "^PasswordAuthentication no" /etc/ssh/sshd_config 2>/dev/null)
[ "$PASS_AUTH" -ge 1 ] && check "Password auth disabled" "PASS" || check "Password auth disabled" "FAIL"

SSH_PORT=$(grep "^Port " /etc/ssh/sshd_config 2>/dev/null | awk '{print $2}')
[ "$SSH_PORT" != "22" ] && [ -n "$SSH_PORT" ] && check "SSH non-standard port ($SSH_PORT)" "PASS" || check "SSH non-standard port" "FAIL"

ROOT_LOGIN=$(grep "^PermitRootLogin" /etc/ssh/sshd_config 2>/dev/null | awk '{print $2}')
[ "$ROOT_LOGIN" = "prohibit-password" ] || [ "$ROOT_LOGIN" = "without-password" ] && check "Root login key-only" "PASS" || check "Root login key-only" "WARN"

echo ""

# Firewall checks
echo "--- Firewall ---"
FW_ACTIVE=$(iptables -L INPUT 2>/dev/null | grep -c "Chain INPUT")
[ "$FW_ACTIVE" -ge 1 ] && check "Firewall active" "PASS" || check "Firewall active" "FAIL"

ACCEPT_ALL=$(iptables -L INPUT -n 2>/dev/null | grep -c "ACCEPT     all  --  0.0.0.0/0            0.0.0.0/0\s*$")
[ "$ACCEPT_ALL" -eq 0 ] && check "No ACCEPT ALL rules" "PASS" || check "No ACCEPT ALL rules" "FAIL"

AMI_LOCALHOST=$(ss -tlnp 2>/dev/null | grep ":5038" | grep -c "127.0.0.1")
AMI_ALL=$(ss -tlnp 2>/dev/null | grep ":5038" | grep -c "0.0.0.0")
[ "$AMI_ALL" -eq 0 ] && check "AMI localhost only" "PASS" || check "AMI localhost only" "FAIL"

MYSQL_LOCALHOST=$(ss -tlnp 2>/dev/null | grep ":3306" | grep -c "127.0.0.1")
MYSQL_ALL=$(ss -tlnp 2>/dev/null | grep ":3306" | grep -c "0.0.0.0")
if [ "$MYSQL_ALL" -eq 0 ]; then
    check "MySQL localhost only" "PASS"
else
    # Could be intentional for cluster
    check "MySQL on all interfaces (verify cluster)" "WARN"
fi

echo ""

# fail2ban checks
echo "--- fail2ban ---"
F2B_RUNNING=$(systemctl is-active fail2ban 2>/dev/null)
[ "$F2B_RUNNING" = "active" ] && check "fail2ban running" "PASS" || check "fail2ban running" "FAIL"

if [ "$F2B_RUNNING" = "active" ]; then
    JAIL_COUNT=$(fail2ban-client status 2>/dev/null | grep "Number of jail" | awk '{print $NF}')
    [ "${JAIL_COUNT:-0}" -ge 3 ] && check "At least 3 jails active ($JAIL_COUNT)" "PASS" || check "At least 3 jails active (${JAIL_COUNT:-0})" "WARN"
fi

echo ""

# Asterisk checks
echo "--- Asterisk ---"
if [ -f /etc/asterisk/sip.conf ]; then
    ALWAYS_REJECT=$(grep -c "alwaysauthreject.*=.*yes" /etc/asterisk/sip.conf 2>/dev/null)
    [ "$ALWAYS_REJECT" -ge 1 ] && check "alwaysauthreject enabled" "PASS" || check "alwaysauthreject enabled" "FAIL"

    NO_GUEST=$(grep -c "allowguest.*=.*no" /etc/asterisk/sip.conf 2>/dev/null)
    [ "$NO_GUEST" -ge 1 ] && check "allowguest disabled" "PASS" || check "allowguest disabled" "FAIL"
fi

echo ""

# Apache checks
echo "--- Apache ---"
APACHE_CONF=$(ls /etc/apache2/httpd.conf /etc/httpd/conf/httpd.conf /etc/apache2/apache2.conf 2>/dev/null | head -1)
if [ -n "$APACHE_CONF" ]; then
    SERVER_TOKENS=$(grep -c "ServerTokens Prod" "$APACHE_CONF" 2>/dev/null)
    [ "$SERVER_TOKENS" -ge 1 ] && check "ServerTokens Prod" "PASS" || check "ServerTokens Prod" "WARN"

    SIG_OFF=$(grep -c "ServerSignature Off" "$APACHE_CONF" 2>/dev/null)
    [ "$SIG_OFF" -ge 1 ] && check "ServerSignature Off" "PASS" || check "ServerSignature Off" "WARN"
fi

EXPOSE_PHP=$(php -i 2>/dev/null | grep "expose_php" | grep -c "Off")
[ "${EXPOSE_PHP:-0}" -ge 1 ] && check "PHP expose_php Off" "PASS" || check "PHP expose_php Off" "WARN"

echo ""

# Database checks
echo "--- Database ---"
if command -v mysql &>/dev/null; then
    NO_PASS_USERS=$(mysql -u root -e "SELECT COUNT(*) FROM mysql.user WHERE authentication_string='' OR authentication_string IS NULL;" -N 2>/dev/null)
    [ "${NO_PASS_USERS:-1}" -eq 0 ] && check "No passwordless DB users" "PASS" || check "No passwordless DB users" "FAIL"

    MAX_STMT=$(mysql -u root -e "SHOW VARIABLES LIKE 'max_statement_time';" -N 2>/dev/null | awk '{print $2}')
    [ "${MAX_STMT:-0}" != "0" ] && [ -n "$MAX_STMT" ] && check "Query timeout set ($MAX_STMT)" "PASS" || check "Query timeout set" "WARN"
fi

echo ""

# Backup checks
echo "--- Backups ---"
LAST_BACKUP=$(ls -t /root/backups/db_*.sql.gz 2>/dev/null | head -1)
if [ -n "$LAST_BACKUP" ]; then
    BACKUP_AGE=$(( ($(date +%s) - $(stat -c%Y "$LAST_BACKUP" 2>/dev/null || echo 0)) / 86400 ))
    [ "$BACKUP_AGE" -le 2 ] && check "Recent backup exists (${BACKUP_AGE}d ago)" "PASS" || check "Recent backup (${BACKUP_AGE}d ago)" "WARN"
else
    check "Database backup exists" "FAIL"
fi

echo ""
echo "============================================================"
echo "  RESULTS: $PASS passed, $WARN warnings, $FAIL failed"
echo "  Score: $(( PASS * 100 / (PASS + WARN + FAIL) ))%"
echo "============================================================"
chmod +x /usr/local/bin/security-audit-auto.sh

# Run monthly on the 1st at 07:00
(crontab -l 2>/dev/null; echo "0 7 1 * * /usr/local/bin/security-audit-auto.sh >> /var/log/monthly-security-audit.log 2>&1") | crontab -

13. Real Incident Case Studies

These are sanitized accounts of real security incidents on production ViciDial servers. Names, IPs, and company details have been changed, but the technical details, timelines, and lessons learned are authentic.

Case Study 1: SIP Brute Force Stopped by fail2ban

Timeline:

What worked: The fail2ban jail with a 3-attempt threshold caught the attack within 3 minutes. The 24-hour ban was sufficient — the scanner did not return.

What could be improved: SIP was still open to the world (port 5060 accessible from any IP). After this incident, SIP was restricted to trunk provider IPs only via iptables, making the fail2ban jail a backup rather than the primary defense.

Asterisk log excerpt (sanitized):

[2026-01-18 02:15:33] NOTICE: Registration from '"100"<sip:100@SERVER_IP>' failed for '203.0.113.47:5060' - No matching peer found
[2026-01-18 02:15:33] NOTICE: Registration from '"101"<sip:101@SERVER_IP>' failed for '203.0.113.47:5060' - No matching peer found
[2026-01-18 02:15:34] NOTICE: Registration from '"admin"<sip:admin@SERVER_IP>' failed for '203.0.113.47:5060' - No matching peer found
[...847 more attempts over 3 minutes...]

Case Study 2: Toll Fraud via Compromised SIP Peer

Timeline:

What failed:

  1. No fail2ban — 50,000 SIP auth attempts went undetected
  2. Weak SIP password — 6-digit numeric password brute-forced in ~90 minutes
  3. No after-hours call monitoring
  4. No concurrent call limit on the SIP peer
  5. No international call restrictions in the dialplan

What was fixed:

  1. fail2ban installed with Asterisk SIP jail (3 attempts, 24-hour ban)
  2. All SIP passwords regenerated to 24+ characters
  3. SIP port restricted to trunk provider IPs via iptables
  4. Toll fraud detection script running every 6 hours
  5. Trunk provider set daily spend cap to $200

Case Study 3: CVE-2024-8503 Exploitation Blocked by IP Restriction

Timeline:

What worked: The Apache IP restriction on the /vicidial/ directory prevented exploitation even though the vulnerable code was present (ViciDial had not been patched). This is defense in depth — the code was vulnerable, but the network-level control prevented access.

What was fixed afterward:

  1. ViciDial patched to SVN 3848+ (fixes CVE-2024-8503 and CVE-2024-8504)
  2. Agent panel also received IP restriction (VPN-only access)
  3. HTTPS enforced for both panels
  4. fail2ban apache-viciauth jail added to catch future scanning

Apache access log excerpt (sanitized):

203.0.113.99 - - [12/Nov/2025:14:23:01 +0000] "POST /vicidial/login.php HTTP/1.1" 403 199 "-" "Mozilla/5.0"
203.0.113.99 - - [12/Nov/2025:14:23:02 +0000] "POST /vicidial/login.php HTTP/1.1" 403 199 "-" "Mozilla/5.0"
203.0.113.99 - - [12/Nov/2025:14:23:04 +0000] "GET /vicidial/admin.php?user=admin'%20OR%201=1-- HTTP/1.1" 403 199 "-" "Mozilla/5.0"

Case Study 4: Misconfigured Ring Group Dropping Calls

Timeline:

What failed:

  1. The ring group was the last safety net for callers when no agents were logged in
  2. The change was made directly in production without testing
  3. No monitoring for call volume drops on that DID
  4. No backup of the config file before editing

What was fixed:

  1. Ring group extensions now have comments explaining their purpose
  2. All dialplan changes require explicit approval and a backup (cp file file.bak.YYYYMMDD)
  3. Monitoring added for call volume drops (alerts if call count drops >50% vs. same day previous week)
  4. A rule was established: never replace a ring group with Hangup() — if the goal is to stop routing, change the ViciDial inbound group action instead of the Asterisk dialplan

Key lesson: Ring groups and fallback extensions are safety nets. They exist for the cases you have not thought of. Removing them causes silent failures that are only detected when customers complain — and by then, you have already lost calls and trust.


Conclusion

Security is not a one-time task — it is an ongoing process. The measures in this guide provide a strong foundation, but they require maintenance:

The most important takeaway: defense in depth. No single measure is sufficient. Each layer catches what the previous layer missed:

  1. Firewall blocks most traffic before it reaches any service
  2. fail2ban catches what gets through the firewall
  3. Strong passwords and ACLs prevent successful authentication even if fail2ban misses the attempt
  4. Dialplan restrictions prevent toll fraud even if a peer is compromised
  5. Monitoring detects what all other measures missed
  6. Backups let you recover when everything else fails

Apply these layers, maintain them, and your ViciDial server will be significantly harder to compromise than the thousands of unprotected installations that attackers will target instead.


Tutorial 36 of the Production VoIP Tutorial Series Technologies: ViciDial, Asterisk, fail2ban, UFW, iptables, Apache, MariaDB, Let's Encrypt, Loki

Need expert help with your setup?

VoIP infrastructure consulting, AI voice agent integration, monitoring stacks, scaling — I've done it all in production.

Get a Free Consultation