Learn how to lock down your ViciDial admin panel with production-grade security: enforce HTTPS, restrict access by IP, implement two-factor authentication, and harden the database layer.
Prerequisites
Before implementing these security measures, ensure you have:
- A running ViciDial installation (v2.14+ recommended, though applicable to v2.9+)
- Root or sudo access to your ViciDial server
- An SSL/TLS certificate (self-signed or from a CA like Let's Encrypt)
- Asterisk 11+ installed and running
- Apache or Nginx web server (this guide covers Apache primarily)
- MySQL/MariaDB database access with root credentials
- Basic Linux command-line competency
- Backup of your ViciDial database before making changes
- A test account to verify 2FA without locking out your main account
Verify your current setup:
mysql -V
apache2 -v
asterisk -V
Why Securing the Admin Interface Matters
The ViciDial admin interface (/vicidial/admin.php) controls:
- User and agent account creation/deletion
- Campaign configuration and call routing
- Pause codes, dispositions, and call outcomes
- Database access and backups
- System logs and call recordings
Unauthorized access to the admin panel can result in:
- Malicious campaign deployment
- Call data theft and GDPR violations
- Agent credential compromise
- Call recording exfiltration
- Service disruption and DDoS via call flooding
This tutorial covers three complementary layers of defense.
Section 1: Enforce HTTPS and Disable HTTP
1.1 Obtain an SSL/TLS Certificate
If you don't have a certificate, obtain one using Let's Encrypt (free):
apt-get update
apt-get install certbot python3-certbot-apache -y
certbot certonly --standalone -d admin.yourdomain.com -d vicidial.yourdomain.com
Follow the prompts. Certificates are saved to /etc/letsencrypt/live/admin.yourdomain.com/.
For self-signed certificates (test environments only):
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout /etc/ssl/private/vicidial-self.key \
-out /etc/ssl/certs/vicidial-self.crt \
-subj "/C=US/ST=State/L=City/O=Company/CN=vicidial.local"
1.2 Configure Apache for HTTPS
Enable the SSL module:
a2enmod ssl
a2enmod rewrite
systemctl restart apache2
Locate your ViciDial VirtualHost configuration:
find /etc/apache2 -name "*vicidial*" -o -name "*.conf" | grep -i vicidial
If none exists, create one at /etc/apache2/sites-available/vicidial-ssl.conf:
<VirtualHost *:80>
ServerName admin.yourdomain.com
DocumentRoot /var/www/html
# Force all HTTP traffic to HTTPS
RewriteEngine On
RewriteCond %{HTTPS} off
RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
</VirtualHost>
<VirtualHost *:443>
ServerName admin.yourdomain.com
DocumentRoot /var/www/html
SSLEngine on
SSLCertificateFile /etc/letsencrypt/live/admin.yourdomain.com/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/admin.yourdomain.com/privkey.pem
# Modern SSL/TLS configuration
SSLProtocol TLSv1.2 TLSv1.3
SSLCipherSuite ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256
SSLHonorCipherOrder on
SSLCompression off
# HSTS - Force HTTPS for 1 year
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"
# Disable server signature exposure
Header always unset "Server"
Header always unset "X-Powered-By"
ServerTokens Prod
<Directory /var/www/html/vicidial>
DirectoryIndex index.html admin.php
AllowOverride All
Require all granted
# Disable directory listing
Options -Indexes
</Directory>
# Proxy to Asterisk AGI if needed
ProxyPreserveHost On
<Location /agi-bin/>
ProxyPass http://127.0.0.1:8088/agi-bin/
ProxyPassReverse http://127.0.0.1:8088/agi-bin/
</Location>
ErrorLog ${APACHE_LOG_DIR}/vicidial-error.log
CustomLog ${APACHE_LOG_DIR}/vicidial-access.log combined
</VirtualHost>
Enable the site:
a2ensite vicidial-ssl
apache2ctl configtest
systemctl restart apache2
Verify HTTPS is working:
curl -I https://admin.yourdomain.com 2>/dev/null | head -5
You should see 200 OK and HTTP/1.1 or HTTP/2.0.
1.3 Disable Weak Protocols and Ciphers
Verify your SSL configuration with testssl.sh or similar:
apt-get install testssl.sh
testssl.sh https://admin.yourdomain.com 2>/dev/null | grep -i "protocol\|cipher"
Remove any references to SSLv3, TLSv1.0, or TLSv1.1 from /etc/apache2/mods-available/ssl.conf:
SSLProtocol -all +TLSv1.2 +TLSv1.3
SSLCipherSuite 'ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256'
Restart Apache:
systemctl restart apache2
Section 2: IP Whitelist for Admin Access
2.1 Implement IP Filtering at Apache Level
Edit /etc/apache2/sites-available/vicidial-ssl.conf to restrict access to /vicidial/admin.php:
<VirtualHost *:443>
ServerName admin.yourdomain.com
# ... SSL config as above ...
<Location /vicidial/admin.php>
# Whitelist office IPs
Require ip 203.0.113.10
Require ip 203.0.113.11
Require ip 198.51.100.0/24
# Log denied access attempts
SetEnvIf Request_URI "/vicidial/admin.php" admin_access
</Location>
<Directory /var/www/html/vicidial>
# ... other directives ...
</Directory>
</VirtualHost>
# Log admin access to separate file
CustomLog ${APACHE_LOG_DIR}/vicidial-admin-access.log combined env=admin_access
Test the configuration:
apache2ctl configtest
systemctl restart apache2
Verify from an allowed IP:
curl -k https://admin.yourdomain.com/vicidial/admin.php 2>&1 | head -10
From a blocked IP, you should receive a 403 Forbidden error.
2.2 Create a Dynamic IP Whitelist File
For environments where admin IPs change frequently, use an include file:
Create /etc/apache2/vicidial-whitelist.conf:
<If "%{REQUEST_URI} =~ m#/vicidial/admin\.php#">
Require ip 203.0.113.10 203.0.113.11
Require ip 198.51.100.0/24
Require ip 192.168.1.0/24
</If>
Include it in your VirtualHost:
<VirtualHost *:443>
# ... SSL config ...
Include /etc/apache2/vicidial-whitelist.conf
</VirtualHost>
To add IPs dynamically without restarting Apache:
echo " 203.0.113.99" >> /etc/apache2/vicidial-whitelist.conf
systemctl reload apache2
2.3 Add ModSecurity Rules (Optional but Recommended)
Install ModSecurity:
apt-get install libapache2-mod-security2 modsecurity-crs -y
a2enmod security2
Create /etc/apache2/conf-available/vicidial-modsecurity.conf:
SecRuleEngine On
SecRequestBodyAccess On
SecRequestBodyLimit 16777216
SecRequestBodyNoFiles Off
# Log all requests to admin interface
SecRule REQUEST_URI "@beginsWith /vicidial/admin.php" \
"id:1001,phase:1,log,logdata:%{REMOTE_ADDR},pass"
# Block common injection attacks
SecRule ARGS "@rx (?:union|select|insert|update|delete|drop)" \
"id:1002,phase:2,deny,status:403,msg:'SQL Injection attempt'"
SecRule ARGS "@rx (?:\.\./|\.\.\\)" \
"id:1003,phase:2,deny,status:403,msg:'Path traversal attempt'"
Enable the module:
a2enconf vicidial-modsecurity
systemctl restart apache2
Monitor ModSecurity logs:
tail -f /var/log/apache2/modsec_audit.log | grep -i vicidial
Section 3: Implement Two-Factor Authentication (2FA)
3.1 Choose a 2FA Method
ViciDial does not include native 2FA in the admin interface. We'll implement 2FA by:
- Database-level tracking of OTP secrets and verified states
- PHP wrapper that intercepts login attempts
- TOTP generation using Google Authenticator or Authy
3.2 Prepare the Database
Create a new table to store TOTP secrets and recovery codes:
USE asterisk;
CREATE TABLE IF NOT EXISTS `vicidial_admin_2fa` (
`user_id` INT NOT NULL,
`username` VARCHAR(20) NOT NULL,
`totp_secret` VARCHAR(32),
`totp_enabled` ENUM('Y','N') DEFAULT 'N',
`backup_codes` TEXT,
`created_date` DATETIME DEFAULT CURRENT_TIMESTAMP,
`last_verified` DATETIME,
PRIMARY KEY (`user_id`),
FOREIGN KEY (`user_id`) REFERENCES `vicidial_users`(`user_id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Add 2FA attempt logging
CREATE TABLE IF NOT EXISTS `vicidial_2fa_audit` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`username` VARCHAR(20) NOT NULL,
`attempt_type` ENUM('LOGIN','OTP_VERIFY','BACKUP_CODE') NOT NULL,
`result` ENUM('SUCCESS','FAILURE','INVALID_USER') NOT NULL,
`remote_ip` VARCHAR(45),
`attempt_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX (`username`),
INDEX (`attempt_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
Verify table creation:
mysql -u root -p asterisk -e "DESCRIBE vicidial_admin_2fa;"
3.3 Install PHP TOTP Library
Navigate to the ViciDial web root:
cd /var/www/html/vicidial
Install PHPGangsta_GoogleAuthenticator (TOTP implementation):
apt-get install php-composer -y
composer require phpgangsta/googleauthenticator 1.0.*
Or download manually:
curl -L -o /var/www/html/vicidial/GoogleAuthenticator.php \
https://raw.githubusercontent.com/PHPGangsta/GoogleAuthenticator/master/PHPGangsta/GoogleAuthenticator.php
Verify installation:
ls -la /var/www/html/vicidial/GoogleAuthenticator.php
3.4 Create 2FA Verification Script
Create /var/www/html/vicidial/admin_2fa_login.php:
<?php
/**
* ViciDial Admin 2FA Login Handler
* Validates TOTP before granting access to admin.php
*/
session_start();
error_reporting(0);
// Database configuration
$db_host = 'localhost';
$db_user = 'root';
$db_pass = 'YOUR_DB_PASSWORD';
$db_name = 'asterisk';
$mysqli = new mysqli($db_host, $db_user, $db_pass, $db_name);
if ($mysqli->connect_error) {
die('Database connection failed: ' . $mysqli->connect_error);
}
// Include TOTP library
require_once 'GoogleAuthenticator.php';
$ga = new PHPGangsta_GoogleAuthenticator();
$username = '';
$error_msg = '';
$info_msg = '';
// Handle OTP verification
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (isset($_POST['action']) && $_POST['action'] === 'verify_otp') {
$username = sanitizeInput($_POST['username'] ?? '');
$otp_code = sanitizeInput($_POST['otp_code'] ?? '');
$remote_ip = $_SERVER['REMOTE_ADDR'];
// Validate inputs
if (empty($username) || empty($otp_code)) {
$error_msg = 'Username and OTP code required';
logAuditEvent($mysqli, $username, 'OTP_VERIFY', 'FAILURE', $remote_ip, 'Empty fields');
} else {
// Fetch TOTP secret from database
$stmt = $mysqli->prepare("
SELECT totp_secret FROM vicidial_admin_2fa
WHERE username = ? AND totp_enabled = 'Y'
");
$stmt->bind_param('s', $username);
$stmt->execute();
$result = $stmt->get_result();
if ($result->num_rows === 0) {
$error_msg = 'User not found or 2FA not enabled';
logAuditEvent($mysqli, $username, 'OTP_VERIFY', 'INVALID_USER', $remote_ip);
} else {
$row = $result->fetch_assoc();
$totp_secret = $row['totp_secret'];
// Verify the OTP (allows 1 minute window for clock drift)
$checkResult = $ga->verifyCode($totp_secret, $otp_code, 1);
if ($checkResult) {
// OTP valid - allow access
$_SESSION['2fa_verified'] = true;
$_SESSION['admin_user'] = $username;
$_SESSION['2fa_verify_time'] = time();
// Update last verified timestamp
$stmt = $mysqli->prepare("
UPDATE vicidial_admin_2fa
SET last_verified = NOW()
WHERE username = ?
");
$stmt->bind_param('s', $username);
$stmt->execute();
logAuditEvent($mysqli, $username, 'OTP_VERIFY', 'SUCCESS', $remote_ip);
// Redirect to admin.php
header('Location: admin.php');
exit;
} else {
$error_msg = 'Invalid OTP code. Please try again.';
logAuditEvent($mysqli, $username, 'OTP_VERIFY', 'FAILURE', $remote_ip, 'Invalid OTP');
}
}
$stmt->close();
}
}
}
function sanitizeInput($input) {
return htmlspecialchars(trim($input), ENT_QUOTES, 'UTF-8');
}
function logAuditEvent($mysqli, $username, $type, $result, $ip, $notes = '') {
$stmt = $mysqli->prepare("
INSERT INTO vicidial_2fa_audit (username, attempt_type, result, remote_ip)
VALUES (?, ?, ?, ?)
");
$stmt->bind_param('ssss', $username, $type, $result, $ip);
$stmt->execute();
$stmt->close();
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ViciDial Admin - Two-Factor Authentication</title>
<style>
body {
font-family: Arial, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
}
.login-container {
background: white;
padding: 40px;
border-radius: 8px;
box-shadow: 0 10px 25px rgba(0,0,0,0.2);
width: 100%;
max-width: 400px;
}
.login-container h1 {
text-align: center;
color: #333;
margin-bottom: 30px;
}
.form-group {
margin-bottom: 20px;
}
label {
display: block;
margin-bottom: 5px;
color: #555;
font-weight: bold;
}
input[type="text"],
input[type="password"] {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
box-sizing: border-box;
font-size: 16px;
}
input[type="text"]:focus,
input[type="password"]:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 5px rgba(102,126,234,0.3);
}
button {
width: 100%;
padding: 12px;
background: #667eea;
color: white;
border: none;
border-radius: 4px;
font-size: 16px;
font-weight: bold;
cursor: pointer;
transition: background 0.3s;
}
button:hover {
background: #764ba2;
}
.error {
background: #fee;
color: #c33;
padding: 12px;
border-radius: 4px;
margin-bottom: 20px;
border-left: 4px solid #c33;
}
.info {
background: #e3f2fd;
color: #1976d2;
padding: 12px;
border-radius: 4px;
margin-bottom: 20px;
border-left: 4px solid #1976d2;
font-size: 14px;
}
</style>
</head>
<body>
<div class="login-container">
<h1>🔐 ViciDial Admin</h1>
<?php if ($error_msg): ?>
<div class="error"><?php echo $error_msg; ?></div>
<?php endif; ?>
<?php if ($info_msg): ?>
<div class="info"><?php echo $info_msg; ?></div>
<?php endif; ?>
<form method="POST">
<input type="hidden" name="action" value="verify_otp">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" placeholder="Admin username" required autofocus>
</div>
<div class="form-group">
<label for="otp_code">Authenticator Code</label>
<input type="text" id="otp_code" name="otp_code" placeholder="000000" maxlength="6" pattern="\d{6}" required>
<div class="info" style="margin-top: 8px; margin-bottom: 0;">
Enter the 6-digit code from your authenticator app (Google Authenticator, Authy, etc.)
</div>
</div>
<button type="submit">Verify & Access Admin Panel</button>
</form>
</div>
</body>
</html>
Set proper permissions:
chown www-data:www-data /var/www/html/vicidial/admin_2fa_login.php
chmod 640 /var/www/html/vicidial/admin_2fa_login.php
3.5 Wrap the Existing admin.php to Enforce 2FA
Create a backup of the original admin.php:
cp /var/www/html/vicidial/admin.php /var/www/html/vicidial/admin.php.bak
Create /var/www/html/vicidial/admin_2fa_check.php (new wrapper):
<?php
/**
* 2FA Check Wrapper for ViciDial Admin
* Must be included at the top of admin.php
*/
session_start();
$config = array(
'db_host' => 'localhost',
'db_user' => 'root',
'db_pass' => 'YOUR_DB_PASSWORD',
'db_name' => 'asterisk',
'2fa_enabled' => true, // Set to false to disable 2FA globally
'2fa_session_timeout' => 3600 // 1 hour in seconds
);
// List of admin usernames that MUST use 2FA
$mandatory_2fa_users = array(
'admin',
'root',
'superadmin'
);
// Check if user is already verified in this session
if (isset($_SESSION['2fa_verified']) && isset($_SESSION['admin_user'])) {
// Check session timeout
if (isset($_SESSION['2fa_verify_time'])) {
if ((time() - $_SESSION['2fa_verify_time']) < $config['2fa_session_timeout']) {
// Session still valid - continue to admin.php
return true;
} else {
// Session expired - force re-authentication
session_destroy();
header('Location: admin_2fa_login.php');
exit;
}
}
}
// User not yet verified - redirect to 2FA login
if ($config['2fa_enabled']) {
// Check if accessing admin.php directly
if (basename($_SERVER['PHP_SELF']) === 'admin.php') {
header('Location: admin_2fa_login.php');
exit;
}
}
Now, modify the original /var/www/html/vicidial/admin.php to include the 2FA check at the very top (after <?php):
<?php
// Add this immediately after opening PHP tag
require_once('admin_2fa_check.php');
// ... rest of original admin.php code ...
Or, create a new admin.php that acts as a wrapper:
<?php
require_once('admin_2fa_check.php');
include('admin.php.bak');
?>
3.6 Generate QR Codes for User Enrollment
Create /var/www/html/vicidial/setup_2fa.php (accessible only to logged-in admins):
<?php
session_start();
error_reporting(0);
$db_host = 'localhost';
$db_user = 'root';
$db_pass = 'YOUR_DB_PASSWORD';
$db_name = 'asterisk';
$mysqli = new mysqli($db_host, $db_user, $db_pass, $db_name);
require_once 'GoogleAuthenticator.php';
$ga = new PHPGangsta_GoogleAuthenticator();
// Only accessible via POST with proper session
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
die('Invalid request');
}
// Generate new TOTP secret
$secret = $ga->createSecret();
$username = sanitizeInput($_POST['username'] ?? '');
if (!$username) {
die('Username required');
}
// Generate QR code URL
$qr_url = $ga->getQRCodeGoogleUrl('ViciDial Admin', $secret);
// Generate backup codes (10 codes)
$backup_codes = array();
for ($i = 0; $i < 10; $i++) {
$backup_codes[] = bin2hex(random_bytes(4));
}
$backup_codes_json = json_encode($backup_codes);
// Store in database (not yet enabled until user confirms)
$stmt = $mysqli->prepare("
INSERT INTO vicidial_admin_2fa (username, totp_secret, backup_codes, totp_enabled)
VALUES (?, ?, ?, 'N')
ON DUPLICATE KEY UPDATE
totp_secret = VALUES(totp_secret),
backup_codes = VALUES(backup_codes),
totp_enabled = 'N'
");
$stmt->bind_param('sss', $username, $secret, $backup_codes_json);
$stmt->execute();
$stmt->close();
// Return JSON response
header('Content-Type: application/json');
echo json_encode(array(
'success' => true,
'qr_code_url' => $qr_url,
'secret' => $secret,
'backup_codes' => $backup_codes
));
function sanitizeInput($input) {
return htmlspecialchars(trim($input), ENT_QUOTES, 'UTF-8');
}
?>
3.7 Enable 2FA for Specific Users
Run this SQL to enable 2FA for critical admin accounts:
UPDATE vicidial_admin_2fa
SET totp_enabled = 'Y'
WHERE username IN ('admin', 'superadmin');
-- View current 2FA status
SELECT username, totp_enabled, last_verified, created_date
FROM vicidial_admin_2fa
ORDER BY created_date DESC;
Monitor 2FA login attempts:
mysql -u root -p asterisk -e "
SELECT username, attempt_type, result, remote_ip, attempt_time
FROM vicidial_2fa_audit
WHERE attempt_time >= DATE_SUB(NOW(), INTERVAL 24 HOUR)
ORDER BY attempt_time DESC;
"
Section 4: Harden Database and Related Access
4.1 Restrict MySQL Access
By default, MySQL listens on all interfaces. Restrict it to localhost:
Edit /etc/mysql/mysql.conf.d/mysqld.cnf:
[mysqld]
bind-address = 127.0.0.1
skip-external-locking
# Disable DNS resolution (faster, more secure)
skip-name-resolve
# Disable file-based access
skip-load-data-local-infile
Restart MySQL:
systemctl restart mysql
4.2 Create Least-Privilege Database User
Instead of using root for the web application, create a dedicated user:
-- Create new user for web app
CREATE USER 'vicidial_web'@'localhost' IDENTIFIED BY 'COMPLEX_PASSWORD_HERE';
-- Grant minimal privileges
GRANT SELECT, INSERT, UPDATE ON asterisk.* TO 'vicidial_web'@'localhost';
GRANT SELECT ON asterisk.vicidial_users TO 'vicidial_web'@'localhost';
GRANT SELECT ON asterisk.vicidial_admin_2fa TO 'vicidial_web'@'localhost';
GRANT INSERT ON asterisk.vicidial_2fa_audit TO 'vicidial_web'@'localhost';
-- Apply changes
FLUSH PRIVILEGES;
-- Verify
SELECT user, host, authentication_string FROM mysql.user WHERE user = 'vicidial_web';
SHOW GRANTS FOR 'vicidial_web'@'localhost';
Update your PHP scripts to use this user instead of root.
4.3 Log All Admin Database Queries
Enable query logging for the asterisk database:
SET GLOBAL general_log = 'ON';
SET GLOBAL log_output = 'TABLE';
Query log is stored in mysql.general_log table. To export:
mysql -u root -p asterisk -e "
SELECT event_time, user_host, command_type, argument
FROM mysql.general_log
WHERE db = 'asterisk' AND command_type IN ('Query', 'Execute')
LIMIT 100;
" > /var/log/vicidial-db-queries.log
To disable general log (after investigation):
SET GLOBAL general_log = 'OFF';
Section 5: Monitoring and Logging
5.1 Configure Centralized Logging
Create /var/www/html/vicidial/log_admin_events.php:
<?php
/**
* Log all admin access events to file and database
*/
function logAdminEvent($event_type, $username, $action, $details = array()) {
$log_file = '/var/log/vicidial/admin-events.log';
// Ensure log directory exists
if (!is_dir('/var/log/vicidial')) {
mkdir('/var/log/vicidial', 0750, true);
chown('/var/log/vicidial', 'www-data');
}
$timestamp = date('Y-m-d H:i:s');
$remote_ip = $_SERVER['REMOTE_ADDR'];
$user_agent = substr($_SERVER['HTTP_USER_AGENT'] ?? 'Unknown', 0, 100);
$log_entry = sprintf(
"[%s] Type:%s | User:%s | Action:%s | IP:%s | UA:%s | Details:%s\n",
$timestamp,
$event_type,
$username,
$action,
$remote_ip,
$user_agent,
json_encode($details)
);
// Write to file
file_put_contents($log_file, $log_entry, FILE_APPEND | LOCK_EX);
// Also log to syslog
openlog('vicidial-admin', LOG_PID | LOG_PERROR, LOG_LOCAL0);
sys