Files
NexusPBX/src/FirewallManager.php
T

878 lines
32 KiB
PHP

<?php
declare(strict_types=1);
namespace App;
use PDO;
use Exception;
use RuntimeException;
use InvalidArgumentException;
class FirewallManager
{
public const JAIL_LOCAL_PATH = '/etc/fail2ban/jail.local';
public const NGINX_CONF_PATH = '/etc/nginx/sites-available/nexus-pbx';
public const WEB_FILTER_PATH = '/etc/fail2ban/filter.d/nexus-web.conf';
public const UCP_FILTER_PATH = '/etc/fail2ban/filter.d/nexus-ucp.conf';
public const UCP_SECURITY_LOG = '/var/log/nexus_ucp_security.log';
public const UCP_ROOT_DIR = '/var/www/nexus-pbx/ucp';
/**
* Récupère la configuration actuelle du pare-feu et de Fail2ban depuis SQLite.
*/
public static function getSettings(): array
{
$db = Database::getConnection();
$stmt = $db->query("SELECT
firewall_enabled, admin_https_port, certbot_strict_port80,
fail2ban_sip_maxretry, fail2ban_sip_findtime, fail2ban_sip_bantime,
fail2ban_web_maxretry, fail2ban_web_bantime, fail2ban_whitelist_ips,
ucp_enabled, ucp_https_port, ucp_http_port,
fail2ban_ucp_maxretry, fail2ban_ucp_bantime
FROM settings WHERE id = 1");
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$row) {
return [
'firewall_enabled' => 1,
'admin_https_port' => 8443,
'certbot_strict_port80' => 0,
'fail2ban_sip_maxretry' => 3,
'fail2ban_sip_findtime' => 600,
'fail2ban_sip_bantime' => 3600,
'fail2ban_web_maxretry' => 3,
'fail2ban_web_bantime' => 3600,
'fail2ban_whitelist_ips' => '127.0.0.1/8 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 82.122.84.60',
'ucp_enabled' => 1,
'ucp_https_port' => 8444,
'ucp_http_port' => 8084,
'fail2ban_ucp_maxretry' => 3,
'fail2ban_ucp_bantime' => 3600,
];
}
return [
'firewall_enabled' => (int)($row['firewall_enabled'] ?? 1),
'admin_https_port' => (int)($row['admin_https_port'] ?? 8443),
'certbot_strict_port80' => (int)($row['certbot_strict_port80'] ?? 0),
'fail2ban_sip_maxretry' => (int)($row['fail2ban_sip_maxretry'] ?? 3),
'fail2ban_sip_findtime' => (int)($row['fail2ban_sip_findtime'] ?? 600),
'fail2ban_sip_bantime' => (int)($row['fail2ban_sip_bantime'] ?? 3600),
'fail2ban_web_maxretry' => (int)($row['fail2ban_web_maxretry'] ?? 3),
'fail2ban_web_bantime' => (int)($row['fail2ban_web_bantime'] ?? 3600),
'fail2ban_whitelist_ips' => (string)($row['fail2ban_whitelist_ips'] ?? '127.0.0.1/8 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 82.122.84.60'),
'ucp_enabled' => (int)($row['ucp_enabled'] ?? 1),
'ucp_https_port' => (int)($row['ucp_https_port'] ?? 8444),
'ucp_http_port' => (int)($row['ucp_http_port'] ?? 8084),
'fail2ban_ucp_maxretry' => (int)($row['fail2ban_ucp_maxretry'] ?? 3),
'fail2ban_ucp_bantime' => (int)($row['fail2ban_ucp_bantime'] ?? 3600),
];
}
/**
* Retourne l'état complet en direct de Fail2ban et les IPs bannies.
* Utilise un cache rapide de 4 secondes pour soulager le tableau de bord temps réel.
*/
public static function getStatus(bool $forceFresh = false): array
{
$cacheFile = '/tmp/nexus_fw_status_cache.json';
if (!$forceFresh && file_exists($cacheFile)) {
$mtime = @filemtime($cacheFile);
if ($mtime && (time() - $mtime) < 4) {
$cached = json_decode((string)file_get_contents($cacheFile), true);
if (is_array($cached)) {
return $cached;
}
}
}
$settings = self::getSettings();
// 1. Détection service Fail2ban (Statut Systemd + vérification socket ping)
$sysActive = trim((string)@shell_exec('systemctl is-active fail2ban 2>/dev/null')) === 'active';
$pingCode = -1;
$pingOut = [];
if ($sysActive) {
exec('timeout 2 sudo -n /usr/bin/fail2ban-client ping 2>&1', $pingOut, $pingCode);
}
$pingOk = ($pingCode === 0 && str_contains(implode(' ', $pingOut), 'Server replied: pong'));
$isRunning = $sysActive || $pingOk;
$jails = [];
$bannedList = [];
$totalBannedCount = 0;
if ($isRunning && $pingOk) {
exec('timeout 2 sudo -n /usr/bin/fail2ban-client status 2>&1', $statOut);
$statStr = implode("\n", $statOut);
// Extraction des prisons actives
if (preg_match('/Jail list:\s+(.+)$/m', $statStr, $m)) {
$jailNames = array_map('trim', explode(',', $m[1]));
foreach ($jailNames as $jName) {
if ($jName === '') continue;
$jStatus = self::getJailDetails($jName);
$jails[$jName] = $jStatus;
foreach ($jStatus['banned_ips'] as $bIp) {
$bannedList[] = [
'ip' => $bIp,
'jail' => $jName,
'jail_label' => self::formatJailName($jName),
];
$totalBannedCount++;
}
}
}
}
// IP actuelle du client (avec résolution reverse proxy si proxy privé/local)
$currentIp = self::resolveClientIp();
$status = [
'is_running' => $isRunning,
'settings' => $settings,
'jails' => $jails,
'banned_ips' => $bannedList,
'total_banned' => $totalBannedCount,
'client_ip' => $currentIp,
];
@file_put_contents($cacheFile, json_encode($status));
return $status;
}
/**
* Débannit une adresse IP d'une prison ou de toutes les prisons.
*/
public static function unbanIp(string $ip, string $jail = ''): array
{
$ip = trim($ip);
if ($ip === '' || (!filter_var($ip, FILTER_VALIDATE_IP) && !filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6))) {
throw new InvalidArgumentException("Adresse IP invalide : {$ip}");
}
$jail = trim($jail);
$unbannedFrom = [];
if ($jail !== '' && $jail !== 'all') {
$cmd = "timeout 2 sudo -n /usr/bin/fail2ban-client set " . escapeshellarg($jail) . " unbanip " . escapeshellarg($ip) . " 2>&1";
exec($cmd, $out, $code);
if ($code === 0) {
$unbannedFrom[] = $jail;
}
} else {
// Débannir de toutes les prisons connues
foreach (['asterisk', 'nexus-web', 'nexus-ucp', 'sshd'] as $j) {
$cmd = "timeout 2 sudo -n /usr/bin/fail2ban-client set " . escapeshellarg($j) . " unbanip " . escapeshellarg($ip) . " 2>&1";
exec($cmd, $out, $code);
if ($code === 0) {
$unbannedFrom[] = $j;
}
}
}
@unlink('/tmp/nexus_fw_status_cache.json');
return [
'success' => count($unbannedFrom) > 0,
'message' => count($unbannedFrom) > 0
? "L'adresse IP {$ip} a été débannie avec succès (" . implode(', ', $unbannedFrom) . ")."
: "L'adresse IP {$ip} n'a pas été trouvée dans les listes de bannissement actives.",
];
}
/**
* Bannit manuellement une adresse IP.
*/
public static function banIp(string $ip, string $jail = 'asterisk'): array
{
$ip = trim($ip);
if ($ip === '' || (!filter_var($ip, FILTER_VALIDATE_IP) && !filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6))) {
throw new InvalidArgumentException("Adresse IP invalide : {$ip}");
}
$jail = trim($jail) ?: 'asterisk';
$cmd = "timeout 2 sudo -n /usr/bin/fail2ban-client set " . escapeshellarg($jail) . " banip " . escapeshellarg($ip) . " 2>&1";
exec($cmd, $out, $code);
@unlink('/tmp/nexus_fw_status_cache.json');
return [
'success' => $code === 0,
'message' => $code === 0
? "L'adresse IP {$ip} a été bannie sur la règle {$jail}."
: "Échec du bannissement : " . implode("\n", $out),
];
}
/**
* Enregistre les paramètres du pare-feu, régénère jail.local et la configuration Nginx.
*/
public static function saveSettings(array $data): array
{
$db = Database::getConnection();
$oldSettings = self::getSettings();
$strictPort80 = !empty($data['certbot_strict_port80']) ? 1 : 0;
$firewallEnabled = isset($data['firewall_enabled']) ? (!empty($data['firewall_enabled']) ? 1 : 0) : 1;
$adminPort = max(1, min(65535, (int)($data['admin_https_port'] ?? 8443)));
$sipMaxRetry = max(1, min(20, (int)($data['fail2ban_sip_maxretry'] ?? 3)));
$sipFindTime = max(60, min(86400, (int)($data['fail2ban_sip_findtime'] ?? 600)));
$sipBanTime = max(60, min(31536000, (int)($data['fail2ban_sip_bantime'] ?? 3600)));
$webMaxRetry = max(1, min(20, (int)($data['fail2ban_web_maxretry'] ?? 3)));
$webBanTime = max(60, min(31536000, (int)($data['fail2ban_web_bantime'] ?? 3600)));
$ucpEnabled = isset($data['ucp_enabled']) ? (!empty($data['ucp_enabled']) ? 1 : 0) : (int)$oldSettings['ucp_enabled'];
$ucpHttpsPort = isset($data['ucp_https_port']) ? max(0, min(65535, (int)$data['ucp_https_port'])) : (int)$oldSettings['ucp_https_port'];
$ucpHttpPort = isset($data['ucp_http_port']) ? max(0, min(65535, (int)$data['ucp_http_port'])) : (int)$oldSettings['ucp_http_port'];
$ucpMaxRetry = isset($data['fail2ban_ucp_maxretry']) ? max(1, min(20, (int)$data['fail2ban_ucp_maxretry'])) : (int)$oldSettings['fail2ban_ucp_maxretry'];
$ucpBanTime = isset($data['fail2ban_ucp_bantime']) ? max(60, min(31536000, (int)$data['fail2ban_ucp_bantime'])) : (int)$oldSettings['fail2ban_ucp_bantime'];
if ($ucpEnabled === 1 && $ucpHttpsPort === 0 && $ucpHttpPort === 0) {
throw new InvalidArgumentException("Lorsque l'UCP est activé, au moins un port (HTTPS ou HTTP) doit être supérieur à 0.");
}
$reservedPorts = [80, 443, 8080, $adminPort, 5060, 5061];
if ($ucpHttpsPort > 0 && in_array($ucpHttpsPort, $reservedPorts, true)) {
throw new InvalidArgumentException("Le port HTTPS UCP ({$ucpHttpsPort}) entre en conflit avec un port réservé (Admin/SIP/ACME).");
}
if ($ucpHttpPort > 0 && in_array($ucpHttpPort, $reservedPorts, true)) {
throw new InvalidArgumentException("Le port HTTP UCP ({$ucpHttpPort}) entre en conflit avec un port réservé (Admin/SIP/ACME).");
}
if ($ucpHttpsPort > 0 && $ucpHttpPort > 0 && $ucpHttpsPort === $ucpHttpPort) {
throw new InvalidArgumentException("Les ports HTTPS et HTTP de l'UCP doivent être différents.");
}
$whitelist = trim((string)($data['fail2ban_whitelist_ips'] ?? ''));
$cleanTokens = array_values(array_filter(preg_split('/\s+/', $whitelist)));
// Toujours préserver l'adresse de bouclage locale pour les processus internes
if (!in_array('127.0.0.1/8', $cleanTokens, true)) {
array_unshift($cleanTokens, '127.0.0.1/8');
}
if (!in_array('::1', $cleanTokens, true)) {
$cleanTokens[] = '::1';
}
$finalWhitelist = implode(' ', array_unique($cleanTokens));
// 1. Sauvegarde dans la base SQLite
$stmt = $db->prepare("UPDATE settings SET
firewall_enabled = :fe,
admin_https_port = :ap,
certbot_strict_port80 = :sp80,
fail2ban_sip_maxretry = :smr,
fail2ban_sip_findtime = :sft,
fail2ban_sip_bantime = :sbt,
fail2ban_web_maxretry = :wmr,
fail2ban_web_bantime = :wbt,
fail2ban_whitelist_ips = :wl,
ucp_enabled = :ue,
ucp_https_port = :uhs,
ucp_http_port = :uhp,
fail2ban_ucp_maxretry = :umr,
fail2ban_ucp_bantime = :ubt,
updated_at = CURRENT_TIMESTAMP
WHERE id = 1");
$stmt->execute([
':fe' => $firewallEnabled,
':ap' => $adminPort,
':sp80' => $strictPort80,
':smr' => $sipMaxRetry,
':sft' => $sipFindTime,
':sbt' => $sipBanTime,
':wmr' => $webMaxRetry,
':wbt' => $webBanTime,
':wl' => $finalWhitelist,
':ue' => $ucpEnabled,
':uhs' => $ucpHttpsPort,
':uhp' => $ucpHttpPort,
':umr' => $ucpMaxRetry,
':ubt' => $ucpBanTime,
]);
// 2. Garantir la présence du filtre & journal Fail2ban UCP puis générer /etc/fail2ban/jail.local
self::ensureUcpFail2banArtifacts();
self::generateJailLocal(
$finalWhitelist,
$sipMaxRetry,
$sipFindTime,
$sipBanTime,
$webMaxRetry,
$webBanTime,
$adminPort,
$ucpEnabled,
$ucpHttpsPort,
$ucpHttpPort,
$ucpMaxRetry,
$ucpBanTime
);
// 3. Rechargement / Redémarrage propre de Fail2ban
exec('timeout 3 sudo -n /usr/bin/fail2ban-client reload 2>&1', $f2bOut, $f2bCode);
if ($f2bCode !== 0) {
exec('timeout 5 sudo -n /usr/bin/systemctl restart fail2ban 2>&1');
}
// 4. Mise à jour de la configuration Nginx & ouverture/fermeture dynamique des ports UCP
self::applyNginxConfig($adminPort, $strictPort80, $ucpEnabled, $ucpHttpsPort, $ucpHttpPort);
self::syncUcpFirewallPorts($oldSettings, [
'ucp_enabled' => $ucpEnabled,
'ucp_https_port' => $ucpHttpsPort,
'ucp_http_port' => $ucpHttpPort,
]);
@unlink('/tmp/nexus_fw_status_cache.json');
return [
'success' => true,
'message' => 'Configuration Pare-feu, UCP & Fail2ban appliquée avec succès ! Nginx et Fail2ban rechargés.',
'settings' => self::getSettings(),
];
}
/**
* Écrit le fichier /etc/fail2ban/jail.local
*/
public static function generateJailLocal(
string $whitelist,
int $sipMaxRetry,
int $sipFindTime,
int $sipBanTime,
int $webMaxRetry,
int $webBanTime,
int $adminPort,
?int $ucpEnabled = null,
?int $ucpHttpsPort = null,
?int $ucpHttpPort = null,
?int $ucpMaxRetry = null,
?int $ucpBanTime = null
): void {
if ($ucpEnabled === null || $ucpHttpsPort === null || $ucpHttpPort === null || $ucpMaxRetry === null || $ucpBanTime === null) {
$st = self::getSettings();
$ucpEnabled = $ucpEnabled ?? $st['ucp_enabled'];
$ucpHttpsPort = $ucpHttpsPort ?? $st['ucp_https_port'];
$ucpHttpPort = $ucpHttpPort ?? $st['ucp_http_port'];
$ucpMaxRetry = $ucpMaxRetry ?? $st['fail2ban_ucp_maxretry'];
$ucpBanTime = $ucpBanTime ?? $st['fail2ban_ucp_bantime'];
}
self::ensureUcpFail2banArtifacts();
$portsWeb = "http,https,{$adminPort}";
$ucpPortsList = [];
if ($ucpHttpsPort > 0) $ucpPortsList[] = (string)$ucpHttpsPort;
if ($ucpHttpPort > 0) $ucpPortsList[] = (string)$ucpHttpPort;
$ucpJailEnabled = ($ucpEnabled === 1 && !empty($ucpPortsList)) ? 'true' : 'false';
$portsUcp = !empty($ucpPortsList) ? implode(',', $ucpPortsList) : '8444';
$conf = <<<EOF
# Fichier généré automatiquement par NexusPBX
# Ne pas modifier manuellement - éditable depuis l'interface Web
[DEFAULT]
ignoreip = {$whitelist}
findtime = {$sipFindTime}
bantime = {$sipBanTime}
maxretry = {$sipMaxRetry}
usedns = no
[sshd]
enabled = true
port = ssh
mode = aggressive
logpath = %(sshd_log)s
backend = %(sshd_backend)s
maxretry = 3
bantime = 3600
[asterisk]
enabled = true
filter = asterisk
logpath = /var/log/asterisk/messages
maxretry = {$sipMaxRetry}
findtime = {$sipFindTime}
bantime = {$sipBanTime}
port = 5060,5061
protocol = udp
[nexus-web]
enabled = true
filter = nexus-web
logpath = /var/log/nexus_security.log
maxretry = {$webMaxRetry}
findtime = 600
bantime = {$webBanTime}
port = {$portsWeb}
protocol = tcp
[nexus-ucp]
enabled = {$ucpJailEnabled}
filter = nexus-ucp
logpath = /var/log/nexus_ucp_security.log
maxretry = {$ucpMaxRetry}
findtime = 600
bantime = {$ucpBanTime}
port = {$portsUcp}
protocol = tcp
EOF;
file_put_contents(self::JAIL_LOCAL_PATH, $conf);
@chgrp(self::JAIL_LOCAL_PATH, 'asterisk');
@chmod(self::JAIL_LOCAL_PATH, 0664);
}
/**
* Met à jour Nginx pour séparer le port 80 (Certbot/ACME), le port d'admin et les VHosts isolés de l'UCP.
*/
public static function applyNginxConfig(
int $adminHttpsPort,
int $strictPort80,
?int $ucpEnabled = null,
?int $ucpHttpsPort = null,
?int $ucpHttpPort = null
): void {
if ($ucpEnabled === null || $ucpHttpsPort === null || $ucpHttpPort === null) {
$st = self::getSettings();
$ucpEnabled = $ucpEnabled ?? $st['ucp_enabled'];
$ucpHttpsPort = $ucpHttpsPort ?? $st['ucp_https_port'];
$ucpHttpPort = $ucpHttpPort ?? $st['ucp_http_port'];
}
$phpSock = '/run/php/php8.4-fpm.sock';
if (!file_exists($phpSock)) {
$found = glob('/run/php/php*-fpm.sock');
if (!empty($found)) {
$phpSock = $found[0];
}
}
// Configuration Port 80 (HTTP)
if ($strictPort80 === 1) {
// Mode Strict : Le port 80 sert UNIQUEMENT aux challenges ACME Certbot
$port80Block = <<<EOF
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
# Challenge ACME Let's Encrypt (Certbot) UNIQUEMENT
location ^~ /.well-known/acme-challenge/ {
root /var/www/nexus-pbx/public;
try_files \$uri =404;
}
# Tout le reste est strictement bloqué sur le port 80
location / {
return 404;
}
}
EOF;
} else {
// Mode Normal : Port 80 sert l'application (et les challenges ACME)
$port80Block = <<<EOF
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
client_max_body_size 512M;
root /var/www/nexus-pbx/public;
index index.php;
access_log /var/log/nginx/nexus-pbx.access.log;
error_log /var/log/nginx/nexus-pbx.error.log;
location / {
try_files \$uri \$uri/ /index.php?\$query_string;
}
location ~* \.(?:css|js)$ {
expires -1;
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
try_files \$uri =404;
}
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:{$phpSock};
fastcgi_param SCRIPT_FILENAME \$document_root\$fastcgi_script_name;
}
location ~ /\.(?!well-known).* {
deny all;
}
}
EOF;
}
// Configuration HTTPS : écoute sur port 443 ET sur le port admin custom si différent de 443
$listenCustom = "";
if ($adminHttpsPort !== 443 && $adminHttpsPort > 0) {
$listenCustom = "\n listen {$adminHttpsPort} ssl;\n listen [::]:{$adminHttpsPort} ssl;";
}
$httpsBlock = <<<EOF
server {
listen 443 ssl default_server;
listen [::]:443 ssl default_server;{$listenCustom}
server_name _;
ssl_certificate /etc/asterisk/keys/active_cert.crt;
ssl_certificate_key /etc/asterisk/keys/active_key.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
client_max_body_size 512M;
root /var/www/nexus-pbx/public;
index index.php;
access_log /var/log/nginx/nexus-pbx.ssl.access.log;
error_log /var/log/nginx/nexus-pbx.ssl.error.log;
location / {
try_files \$uri \$uri/ /index.php?\$query_string;
}
location ~* \.(?:css|js)$ {
expires -1;
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
try_files \$uri =404;
}
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:{$phpSock};
fastcgi_param SCRIPT_FILENAME \$document_root\$fastcgi_script_name;
}
location ~ /\.(?!well-known).* {
deny all;
}
}
EOF;
// Configuration Port 8080 : Annuaire XML pour téléphones IP (Yealink, Fanvil, Grandstream, etc.)
$phonebookBlock = <<<EOF
server {
listen 8080;
listen [::]:8080;
server_name _;
root /var/www/nexus-pbx/public;
# Annuaire XML pour postes IP (phonebook.php)
location = /phonebook.php {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:{$phpSock};
fastcgi_param SCRIPT_FILENAME \$document_root\$fastcgi_script_name;
}
# Avatars / Photos de contacts pour téléphones IP
location ^~ /avatars/ {
try_files \$uri =404;
}
location / {
return 404;
}
}
EOF;
$blocks = [$port80Block, $phonebookBlock, $httpsBlock];
// VirtualHosts Nginx totalement isolés pour l'UCP (/var/www/nexus-pbx/ucp)
if ($ucpEnabled === 1 && is_dir(self::UCP_ROOT_DIR)) {
if ($ucpHttpsPort > 0) {
$blocks[] = self::buildUcpServerBlock($ucpHttpsPort, true, $phpSock);
}
if ($ucpHttpPort > 0) {
$blocks[] = self::buildUcpServerBlock($ucpHttpPort, false, $phpSock);
}
}
$fullNginxConfig = implode("\n\n", $blocks) . "\n";
file_put_contents(self::NGINX_CONF_PATH, $fullNginxConfig);
@chgrp(self::NGINX_CONF_PATH, 'asterisk');
@chmod(self::NGINX_CONF_PATH, 0664);
// Test de syntaxe
exec('sudo -n /usr/sbin/nginx -t 2>&1', $tOut, $tCode);
if ($tCode !== 0) {
throw new RuntimeException("Erreur de syntaxe Nginx : " . implode("\n", $tOut));
}
// Rechargement Nginx sans coupure
exec('sudo -n /usr/bin/systemctl reload nginx 2>&1');
}
private static function buildUcpServerBlock(int $port, bool $ssl, string $phpSock): string
{
$ucpRoot = self::UCP_ROOT_DIR;
$listenDirectives = $ssl
? "listen {$port} ssl;\n listen [::]:{$port} ssl;"
: "listen {$port};\n listen [::]:{$port};";
$sslDirectives = $ssl ? <<<SSL
ssl_certificate /etc/asterisk/keys/active_cert.crt;
ssl_certificate_key /etc/asterisk/keys/active_key.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
SSL : '';
$logPrefix = $ssl ? 'nexus-ucp.ssl' : 'nexus-ucp';
return <<<EOF
server {
{$listenDirectives}
server_name _;{$sslDirectives}
client_max_body_size 16M;
root {$ucpRoot};
index index.php;
access_log /var/log/nginx/{$logPrefix}.access.log;
error_log /var/log/nginx/{$logPrefix}.error.log;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
location / {
try_files \$uri \$uri/ /index.php?\$query_string;
}
location ~* \.(?:css|js)$ {
expires -1;
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
try_files \$uri =404;
}
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:{$phpSock};
fastcgi_param SCRIPT_FILENAME \$document_root\$fastcgi_script_name;
}
location ~ /\. {
deny all;
}
}
EOF;
}
/**
* Crée le filtre et le fichier de log Fail2ban dédiés au portail UCP.
*/
public static function ensureUcpFail2banArtifacts(): void
{
$filterContent = "[Definition]\nfailregex = ^.*\\[NEXUS-UCP-AUTH-FAIL\\]:\\s*<HOST>.*$\nignoreregex =\n";
$isNonRoot = function_exists('posix_geteuid') && posix_geteuid() !== 0;
if (!file_exists(self::UCP_FILTER_PATH) || @file_get_contents(self::UCP_FILTER_PATH) !== $filterContent) {
if (@file_put_contents(self::UCP_FILTER_PATH, $filterContent) !== false) {
@chmod(self::UCP_FILTER_PATH, 0644);
} elseif ($isNonRoot) {
@exec('sudo -n /var/www/nexus-pbx/scripts/nexus-firewall init-ucp >/dev/null 2>&1');
return;
}
}
if (!file_exists(self::UCP_SECURITY_LOG)) {
if (@touch(self::UCP_SECURITY_LOG) === false && $isNonRoot) {
@exec('sudo -n /var/www/nexus-pbx/scripts/nexus-firewall init-ucp >/dev/null 2>&1');
return;
}
}
@chown(self::UCP_SECURITY_LOG, 'www-data');
@chgrp(self::UCP_SECURITY_LOG, 'asterisk');
@chmod(self::UCP_SECURITY_LOG, 0664);
}
/**
* Auto-déploie les VHosts Nginx UCP et le filtre Fail2ban si absents (ex. après git pull).
*/
public static function ensureUcpDeployed(): void
{
$st = self::getSettings();
if (empty($st['ucp_enabled'])) {
return;
}
$httpsPort = (int)($st['ucp_https_port'] ?? 8444);
$httpPort = (int)($st['ucp_http_port'] ?? 8084);
if ($httpsPort <= 0 && $httpPort <= 0) {
return;
}
$nginxFile = '/etc/nginx/sites-available/nexus-pbx';
$nginxConf = is_readable($nginxFile) ? (string)@file_get_contents($nginxFile) : '';
$hasNginxUcp = str_contains($nginxConf, self::UCP_ROOT_DIR);
$hasFilter = file_exists(self::UCP_FILTER_PATH);
$hasLog = file_exists(self::UCP_SECURITY_LOG);
if ($hasNginxUcp && $hasFilter && $hasLog) {
return;
}
if (function_exists('posix_geteuid') && posix_geteuid() !== 0) {
exec('sudo -n /var/www/nexus-pbx/scripts/nexus-firewall init-ucp 2>&1', $out, $code);
if ($code === 0) {
return;
}
}
self::saveSettings($st);
}
/**
* Ouvre/ferme les ports UCP configurés dans UFW / iptables uniquement si un filtrage actif existe.
*/
private static function syncUcpFirewallPorts(array $oldSettings, array $newSettings): void
{
$oldPorts = [];
if (!empty($oldSettings['ucp_enabled'])) {
if ((int)($oldSettings['ucp_https_port'] ?? 0) > 0) $oldPorts[] = (int)$oldSettings['ucp_https_port'];
if ((int)($oldSettings['ucp_http_port'] ?? 0) > 0) $oldPorts[] = (int)$oldSettings['ucp_http_port'];
}
$newPorts = [];
if (!empty($newSettings['ucp_enabled'])) {
if ((int)($newSettings['ucp_https_port'] ?? 0) > 0) $newPorts[] = (int)$newSettings['ucp_https_port'];
if ((int)($newSettings['ucp_http_port'] ?? 0) > 0) $newPorts[] = (int)$newSettings['ucp_http_port'];
}
$toRemove = array_diff(array_unique($oldPorts), array_unique($newPorts));
$toAdd = array_diff(array_unique($newPorts), array_unique($oldPorts));
// UFW si installé et actif
$ufwStatus = trim((string)@shell_exec('sudo -n /usr/sbin/ufw status 2>/dev/null'));
if ($ufwStatus !== '' && stripos($ufwStatus, 'Status: active') !== false) {
foreach ($toRemove as $p) {
@shell_exec('sudo -n /usr/sbin/ufw delete allow ' . (int)$p . '/tcp >/dev/null 2>&1');
}
foreach ($newPorts as $p) {
@shell_exec('sudo -n /usr/sbin/ufw allow ' . (int)$p . '/tcp >/dev/null 2>&1');
}
}
// iptables si la politique INPUT par défaut bloque le trafic (DROP/REJECT)
$iptPolicy = trim((string)@shell_exec("sudo -n /usr/sbin/iptables -S INPUT 2>/dev/null | head -n 1"));
if (str_contains($iptPolicy, 'DROP') || str_contains($iptPolicy, 'REJECT')) {
foreach ($toRemove as $p) {
@shell_exec("sudo -n /usr/sbin/iptables -D INPUT -p tcp --dport " . (int)$p . " -j ACCEPT >/dev/null 2>&1");
}
foreach ($toAdd as $p) {
@shell_exec("sudo -n /usr/sbin/iptables -C INPUT -p tcp --dport " . (int)$p . " -j ACCEPT >/dev/null 2>&1 || sudo -n /usr/sbin/iptables -I INPUT -p tcp --dport " . (int)$p . " -j ACCEPT >/dev/null 2>&1");
}
}
}
/**
* Résout l'IP réelle du client en tenant compte d'un reverse proxy local / RFC1918 de confiance.
*/
public static function resolveClientIp(): string
{
$remote = trim((string)($_SERVER['REMOTE_ADDR'] ?? '127.0.0.1'));
if ($remote === '' || !filter_var($remote, FILTER_VALIDATE_IP)) {
return '127.0.0.1';
}
// Ne faire confiance aux en-têtes proxy que si la connexion TCP provient d'un réseau privé ou loopback
$isTrustedProxy = (
filter_var($remote, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false
);
if ($isTrustedProxy) {
$candidates = [];
if (!empty($_SERVER['HTTP_X_REAL_IP'])) {
$candidates[] = trim((string)$_SERVER['HTTP_X_REAL_IP']);
}
if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
foreach (explode(',', (string)$_SERVER['HTTP_X_FORWARDED_FOR']) as $part) {
$candidates[] = trim($part);
}
}
foreach ($candidates as $cand) {
if ($cand !== '' && filter_var($cand, FILTER_VALIDATE_IP)) {
return $cand;
}
}
}
return $remote;
}
private static function getJailDetails(string $jail): array
{
exec('timeout 2 sudo -n /usr/bin/fail2ban-client status ' . escapeshellarg($jail) . ' 2>&1', $out);
$str = implode("\n", $out);
$currentlyFailed = 0;
$totalFailed = 0;
$currentlyBanned = 0;
$totalBanned = 0;
$bannedIps = [];
if (preg_match('/Currently failed:\s+(\d+)/', $str, $m)) {
$currentlyFailed = (int)$m[1];
}
if (preg_match('/Total failed:\s+(\d+)/', $str, $m)) {
$totalFailed = (int)$m[1];
}
// Intégrer également les événements consignés dans le journal physique
if ($jail === 'asterisk') {
$logIncidents = is_readable('/var/log/asterisk/security.log')
? (int)shell_exec("grep -c -E 'ChallengeResponseFailed|InvalidAccountID|FailedAuth' /var/log/asterisk/security.log 2>/dev/null")
: 0;
$totalFailed = max($totalFailed, $logIncidents);
} elseif ($jail === 'nexus-web') {
$logIncidents = is_readable('/var/log/nexus_security.log')
? (int)shell_exec("grep -c -E 'NEXUS-WEB-AUTH-FAIL|FailedLogin' /var/log/nexus_security.log 2>/dev/null")
: 0;
$totalFailed = max($totalFailed, $logIncidents);
} elseif ($jail === 'nexus-ucp') {
$logIncidents = is_readable(self::UCP_SECURITY_LOG)
? (int)shell_exec("grep -c 'NEXUS-UCP-AUTH-FAIL' " . escapeshellarg(self::UCP_SECURITY_LOG) . " 2>/dev/null")
: 0;
$totalFailed = max($totalFailed, $logIncidents);
}
if (preg_match('/Currently banned:\s+(\d+)/', $str, $m)) {
$currentlyBanned = (int)$m[1];
}
if (preg_match('/Total banned:\s+(\d+)/', $str, $m)) {
$totalBanned = (int)$m[1];
}
if (preg_match('/Banned IP list:\s*(.*)$/m', $str, $m)) {
$rawIps = trim($m[1]);
if ($rawIps !== '') {
$bannedIps = array_values(array_filter(preg_split('/\s+/', $rawIps)));
}
}
return [
'name' => $jail,
'currently_failed' => $currentlyFailed,
'total_failed' => $totalFailed,
'currently_banned' => $currentlyBanned,
'total_banned' => $totalBanned,
'banned_ips' => $bannedIps,
];
}
private static function formatJailName(string $jail): string
{
return match ($jail) {
'asterisk' => 'Téléphonie SIP (PJSIP)',
'nexus-web' => 'Interface Web Admin',
'nexus-ucp' => 'Portail Utilisateur (UCP)',
'sshd' => 'Accès Console SSH',
default => ucfirst($jail),
};
}
}