Initial commit - NexusPBX IP Telephony Platform

This commit is contained in:
sorcier committed 2026-11-02 02:24:47 +01:00
commit c5de8d0c71
25 files changed
+18123

No files matched your search

+227
View File
@@ -0,0 +1,227 @@
<?php
declare(strict_types=1);
namespace App;
use PDO;
use RuntimeException;
class AmiClient
{
public function __construct(
private string $host,
private int $port,
private string $username,
private string $secret,
private int $timeout = 3
) {}
public static function fromDatabase(PDO $db): self
{
$row = $db->query('SELECT ami_host, ami_port, ami_user, ami_secret FROM settings WHERE id = 1')->fetch();
return new self(
(string)($row['ami_host'] ?? '127.0.0.1'),
(int)($row['ami_port'] ?? 5038),
(string)($row['ami_user'] ?? 'pbxgui'),
(string)($row['ami_secret'] ?? 'AsteriskAmi2026!')
);
}
/**
* Teste la connexion et l'authentification AMI
*/
public function testConnection(): array
{
$fp = $this->connectAndLogin($banner);
$versionOutput = $this->sendAction($fp, [
'Action' => 'Command',
'Command' => 'core show version',
]);
$this->logoff($fp);
return [
'connected' => true,
'banner' => trim($banner),
'version' => $this->extractCommandOutput($versionOutput),
];
}
/**
* Recharge à chaud l'ensemble des modules PJSIP, force le Re-Register SIP immédiat des Trunks, et recharge le Dialplan via AMI
*/
public function reloadPjsipAndDialplan(): array
{
$fp = $this->connectAndLogin($banner);
// Recharge l'intégralité de la pile PJSIP (res_pjsip.so, res_pjsip_outbound_registration.so, res_pjsip_endpoint_identifier_ip.so, etc.)
$pjsipRes = $this->sendAction($fp, [
'Action' => 'Command',
'Command' => 'pjsip reload',
]);
// Force l'envoi immédiat d'une requête SIP REGISTER vers tous les opérateurs (reprend immédiatement la main si un autre PBX était enregistré)
$regRes = $this->sendAction($fp, [
'Action' => 'Command',
'Command' => 'pjsip send register *all',
]);
// Recharge le dialplan (extensions.conf / extensions.custom.conf)
$dialplanRes = $this->sendAction($fp, [
'Action' => 'Command',
'Command' => 'dialplan reload',
]);
// Recharge les classes de musique d'attente (musiconhold.conf)
$mohRes = $this->sendAction($fp, [
'Action' => 'Command',
'Command' => 'moh reload',
]);
// Recharge le module RTP (plages de ports rtpstart/rtpend & NAT)
$rtpRes = $this->sendAction($fp, [
'Action' => 'Command',
'Command' => 'module reload res_rtp_asterisk.so',
]);
// Recharge les touches en cours d'appel (features.conf : transferts ##, *2, etc.)
$featuresRes = $this->sendAction($fp, [
'Action' => 'Command',
'Command' => 'module reload features',
]);
// Recharge la messagerie vocale (voicemail.conf & voicemail.custom.conf)
$voicemailRes = $this->sendAction($fp, [
'Action' => 'Command',
'Command' => 'voicemail reload',
]);
$this->logoff($fp);
// Laisse 1.25s au scheduler PJSIP (1s) + registrar opérateur (OVH) pour répondre 200 OK avant le rafraîchissement de l'état UI
usleep(1250000);
return [
'banner' => trim($banner),
'pjsip' => $this->extractCommandOutput($pjsipRes),
'register' => $this->extractCommandOutput($regRes),
'dialplan' => $this->extractCommandOutput($dialplanRes),
'moh' => $this->extractCommandOutput($mohRes),
];
}
/**
* Force l'envoi immédiat d'un paquet SIP REGISTER sur un trunk précis ou tous les trunks (*all)
*/
public function forceRegister(string $regObject = '*all'): string
{
$fp = $this->connectAndLogin($banner);
$raw = $this->sendAction($fp, [
'Action' => 'Command',
'Command' => 'pjsip send register ' . $regObject,
]);
$this->logoff($fp);
usleep(1250000);
return $this->extractCommandOutput($raw);
}
/**
* Exécute une commande CLI Asterisk via AMI (ex: "pjsip show endpoints")
*/
public function runCommand(string $cliCommand): string
{
$fp = $this->connectAndLogin($banner);
$raw = $this->sendAction($fp, [
'Action' => 'Command',
'Command' => $cliCommand,
]);
$this->logoff($fp);
return $this->extractCommandOutput($raw);
}
/**
* @return resource
*/
private function connectAndLogin(?string &$banner = null)
{
$errno = 0;
$errstr = '';
$fp = @fsockopen($this->host, $this->port, $errno, $errstr, $this->timeout);
if (!$fp) {
throw new RuntimeException("Connexion socket AMI refusée sur {$this->host}:{$this->port} ({$errstr} [{$errno}]). Vérifiez qu'Asterisk et manager.conf sont actifs.");
}
stream_set_timeout($fp, $this->timeout);
$banner = (string)fgets($fp, 1024);
$loginResp = $this->sendAction($fp, [
'Action' => 'Login',
'Username' => $this->username,
'Secret' => $this->secret,
'Events' => 'off',
]);
if (!preg_match('/Response:\s*Success/i', $loginResp)) {
fclose($fp);
throw new RuntimeException("Échec d'authentification AMI pour l'utilisateur '{$this->username}' sur {$this->host}:{$this->port}.");
}
return $fp;
}
/**
* @param resource $fp
*/
private function sendAction($fp, array $headers): string
{
$packet = '';
foreach ($headers as $k => $v) {
$packet .= "{$k}: {$v}\r\n";
}
$packet .= "\r\n";
fwrite($fp, $packet);
$response = '';
while (!feof($fp)) {
$line = fgets($fp, 4096);
if ($line === false) {
break;
}
$response .= $line;
if (str_ends_with($response, "\r\n\r\n") || str_ends_with($response, "\n\n")) {
break;
}
}
return $response;
}
/**
* @param resource $fp
*/
private function logoff($fp): void
{
@fwrite($fp, "Action: Logoff\r\n\r\n");
@fclose($fp);
}
private function extractCommandOutput(string $raw): string
{
$lines = preg_split('/\r?\n/', trim($raw)) ?: [];
$clean = [];
foreach ($lines as $line) {
if (preg_match('/^(Response|Privilege|ActionID|Message):/i', $line)) {
continue;
}
if (str_starts_with($line, 'Output:')) {
$val = ltrim(substr($line, 7));
if ($val !== '') {
$clean[] = $val;
}
} elseif ($line !== '--END COMMAND--') {
$clean[] = $line;
}
}
return trim(implode("\n", $clean));
}
}
+81
View File
@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace App;
use PDO;
class Auth
{
public static function startSession(): void
{
if (session_status() === PHP_SESSION_NONE) {
$sessName = isset($_COOKIE['ASTERISK_PBX_SESS']) && !isset($_COOKIE['NEXUSPBX_SESS'])
? 'ASTERISK_PBX_SESS'
: 'NEXUSPBX_SESS';
session_name($sessName);
session_start([
'cookie_httponly' => true,
'cookie_samesite' => 'Lax',
]);
}
}
public static function isAuthenticated(): bool
{
self::startSession();
return !empty($_SESSION['user_id']) && !empty($_SESSION['username']);
}
public static function getUsername(): string
{
self::startSession();
return (string)($_SESSION['username'] ?? '');
}
public static function login(PDO $db, string $username, string $password): bool
{
self::startSession();
$stmt = $db->prepare('SELECT id, username, password_hash FROM users WHERE username = :u LIMIT 1');
$stmt->execute([':u' => trim($username)]);
$user = $stmt->fetch();
if ($user && password_verify($password, $user['password_hash'])) {
session_regenerate_id(true);
$_SESSION['user_id'] = (int)$user['id'];
$_SESSION['username'] = $user['username'];
return true;
}
return false;
}
public static function logout(): void
{
self::startSession();
$_SESSION = [];
if (ini_get('session.use_cookies')) {
$params = session_get_cookie_params();
setcookie(
session_name(),
'',
time() - 42000,
$params['path'],
$params['domain'],
$params['secure'],
$params['httponly']
);
}
session_destroy();
}
public static function updatePassword(PDO $db, string $username, string $newPassword): void
{
$stmt = $db->prepare('UPDATE users SET password_hash = :p WHERE username = :u');
$stmt->execute([
':p' => password_hash($newPassword, PASSWORD_DEFAULT),
':u' => $username,
]);
}
}
+879
View File
@@ -0,0 +1,879 @@
<?php
declare(strict_types=1);
namespace App;
use PDO;
use Exception;
use RuntimeException;
use InvalidArgumentException;
class BackupManager
{
public const BACKUP_DIR = '/var/www/nexus-pbx/storage/system_backups';
public const VERSION = '1.2.0';
/**
* Initialise le répertoire des sauvegardes.
*/
public static function initDirectory(): void
{
if (!is_dir(self::BACKUP_DIR)) {
@mkdir(self::BACKUP_DIR, 0775, true);
}
@chown(self::BACKUP_DIR, 'www-data');
@chgrp(self::BACKUP_DIR, 'asterisk');
@chmod(self::BACKUP_DIR, 02775);
}
/**
* Récupère les paramètres de sauvegarde depuis SQLite.
*/
public static function getBackupSettings(): array
{
$db = Database::getConnection();
$stmt = $db->query("SELECT
backup_auto_enabled, backup_frequency, backup_retention, backup_storage_type,
backup_ssh_host, backup_ssh_port, backup_ssh_user, backup_ssh_auth_type,
backup_ssh_password, backup_ssh_remote_path, backup_last_run, backup_last_status
FROM settings WHERE id = 1");
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$row) {
return [
'backup_auto_enabled' => 0,
'backup_frequency' => 'daily',
'backup_retention' => 7,
'backup_storage_type' => 'local',
'backup_ssh_host' => '',
'backup_ssh_port' => 22,
'backup_ssh_user' => '',
'backup_ssh_auth_type' => 'password',
'backup_ssh_password' => '',
'backup_ssh_remote_path' => '/backups/nexus',
'backup_last_run' => null,
'backup_last_status' => '',
];
}
return [
'backup_auto_enabled' => (int)($row['backup_auto_enabled'] ?? 0),
'backup_frequency' => (string)($row['backup_frequency'] ?? 'daily'),
'backup_retention' => (int)($row['backup_retention'] ?? 7),
'backup_storage_type' => (string)($row['backup_storage_type'] ?? 'local'),
'backup_ssh_host' => (string)($row['backup_ssh_host'] ?? ''),
'backup_ssh_port' => (int)($row['backup_ssh_port'] ?? 22),
'backup_ssh_user' => (string)($row['backup_ssh_user'] ?? ''),
'backup_ssh_auth_type' => (string)($row['backup_ssh_auth_type'] ?? 'password'),
'backup_ssh_password' => (string)($row['backup_ssh_password'] ?? ''),
'backup_ssh_remote_path' => (string)($row['backup_ssh_remote_path'] ?? '/backups/nexus'),
'backup_last_run' => $row['backup_last_run'] ?? null,
'backup_last_status' => (string)($row['backup_last_status'] ?? ''),
];
}
/**
* Sauvegarde les paramètres de configuration des backups.
*/
public static function saveBackupSettings(array $data): void
{
$db = Database::getConnection();
$autoEnabled = !empty($data['backup_auto_enabled']) ? 1 : 0;
$frequency = in_array($data['backup_frequency'] ?? '', ['hourly', '6h', '12h', 'daily', 'weekly'], true)
? $data['backup_frequency']
: 'daily';
$retention = max(1, min(60, (int)($data['backup_retention'] ?? 7)));
$storageType = ($data['backup_storage_type'] ?? 'local') === 'ssh' ? 'ssh' : 'local';
$sshHost = trim((string)($data['backup_ssh_host'] ?? ''));
$sshPort = max(1, min(65535, (int)($data['backup_ssh_port'] ?? 22)));
$sshUser = trim((string)($data['backup_ssh_user'] ?? ''));
$sshAuthType = ($data['backup_ssh_auth_type'] ?? 'password') === 'key' ? 'key' : 'password';
$sshPassword = (string)($data['backup_ssh_password'] ?? '');
$sshRemotePath = trim((string)($data['backup_ssh_remote_path'] ?? '/backups/nexus'));
if ($sshRemotePath === '') {
$sshRemotePath = '/backups/nexus';
}
$stmt = $db->prepare("UPDATE settings SET
backup_auto_enabled = :auto,
backup_frequency = :freq,
backup_retention = :ret,
backup_storage_type = :stype,
backup_ssh_host = :shost,
backup_ssh_port = :sport,
backup_ssh_user = :suser,
backup_ssh_auth_type = :sauth,
backup_ssh_password = :spass,
backup_ssh_remote_path = :spath,
updated_at = CURRENT_TIMESTAMP
WHERE id = 1");
$stmt->execute([
':auto' => $autoEnabled,
':freq' => $frequency,
':ret' => $retention,
':stype' => $storageType,
':shost' => $sshHost,
':sport' => $sshPort,
':suser' => $sshUser,
':sauth' => $sshAuthType,
':spass' => $sshPassword,
':spath' => $sshRemotePath,
]);
}
/**
* Teste la connexion SSH avec les identifiants fournis.
*/
public static function testSshConnection(array $cfg): array
{
$host = trim((string)($cfg['backup_ssh_host'] ?? ''));
$port = (int)($cfg['backup_ssh_port'] ?? 22);
$user = trim((string)($cfg['backup_ssh_user'] ?? ''));
$authType = ($cfg['backup_ssh_auth_type'] ?? 'password') === 'key' ? 'key' : 'password';
$password = (string)($cfg['backup_ssh_password'] ?? '');
$remotePath = trim((string)($cfg['backup_ssh_remote_path'] ?? '/backups/nexus'));
if ($host === '' || $user === '') {
throw new InvalidArgumentException("L'hôte SSH et l'utilisateur distant sont obligatoires.");
}
if ($authType === 'password' && $password === '') {
throw new InvalidArgumentException("Le mot de passe SSH est obligatoire.");
}
$remoteDirEsc = escapeshellarg($remotePath);
$testCmd = "mkdir -p {$remoteDirEsc} && touch {$remoteDirEsc}/.nexus_test && rm -f {$remoteDirEsc}/.nexus_test && echo 'NEXUS_SSH_OK'";
$result = self::runSshCommand($host, $port, $user, $authType, $password, $testCmd);
if ($result['exit_code'] !== 0 || !str_contains($result['output'], 'NEXUS_SSH_OK')) {
$err = trim($result['error'] ?: $result['output']);
return [
'success' => false,
'message' => "Échec de connexion SSH ou dossier distant non accessible : " . ($err ?: 'Erreur inconnue (code ' . $result['exit_code'] . ')'),
];
}
return [
'success' => true,
'message' => "Connexion SSH réussie ! Dossier distant '{$remotePath}' accessible et prêt pour les sauvegardes.",
];
}
/**
* Crée une archive de sauvegarde complète (Base de données SQLite, sons, messageries, certs, confs).
*
* @param string $source 'manual' ou 'cron'
* @return array
*/
public static function createBackup(string $source = 'manual'): array
{
self::initDirectory();
$settings = self::getBackupSettings();
$db = Database::getConnection();
// 1. Checkpoint WAL SQLite pour garantir l'intégrité absolue de la base
try {
$db->exec("PRAGMA wal_checkpoint(TRUNCATE)");
} catch (Exception $e) {
// Ignorer si échec temporaire
}
$timestamp = date('Ymd_His');
$filename = "nexus_backup_v" . self::VERSION . "_{$timestamp}.tar.gz";
$localArchivePath = self::BACKUP_DIR . '/' . $filename;
// 2. Préparation du dossier de staging
$stageDir = '/tmp/nexus_stage_' . uniqid() . '_' . $timestamp;
if (!mkdir($stageDir, 0700, true)) {
throw new RuntimeException("Impossible de créer le répertoire temporaire de staging : {$stageDir}");
}
$manifestFiles = [];
try {
// A. Copie de la base SQLite
$dbSource = Database::getDatabasePath();
if (file_exists($dbSource)) {
$dbDest = $stageDir . '/database.sqlite';
copy($dbSource, $dbDest);
$manifestFiles['database.sqlite'] = [
'size' => filesize($dbDest),
'md5' => md5_file($dbDest),
];
}
// B. Copie des fichiers sons d'accueil et musiques personnalisées
// /var/www/nexus-pbx/storage/sounds/
$storageSounds = '/var/www/nexus-pbx/storage/sounds';
if (is_dir($storageSounds)) {
$destSounds = $stageDir . '/storage_sounds';
mkdir($destSounds, 0755, true);
exec("rsync -a " . escapeshellarg($storageSounds . '/') . " " . escapeshellarg($destSounds . '/'));
$manifestFiles['storage_sounds'] = ['count' => count(glob($destSounds . '/*'))];
}
// /var/lib/asterisk/sounds/custom/
$astSoundsCustom = '/var/lib/asterisk/sounds/custom';
if (is_dir($astSoundsCustom)) {
$destAstSounds = $stageDir . '/asterisk_sounds_custom';
mkdir($destAstSounds, 0755, true);
exec("rsync -a " . escapeshellarg($astSoundsCustom . '/') . " " . escapeshellarg($destAstSounds . '/'));
$manifestFiles['asterisk_sounds_custom'] = ['count' => count(glob($destAstSounds . '/*'))];
}
// C. Spool Voicemail (Messages vocaux, salutations personnalisées, secrets/pins)
$voicemailSpool = '/var/spool/asterisk/voicemail/default';
if (is_dir($voicemailSpool)) {
$destVm = $stageDir . '/voicemail_default';
mkdir($destVm, 0755, true);
exec("rsync -a " . escapeshellarg($voicemailSpool . '/') . " " . escapeshellarg($destVm . '/'));
$manifestFiles['voicemail_default'] = ['count' => count(glob($destVm . '/*'))];
}
// D. Certificats SSL / TLS personnalisés
$storageCerts = '/var/www/nexus-pbx/storage/certs';
if (is_dir($storageCerts)) {
$destCerts = $stageDir . '/storage_certs';
mkdir($destCerts, 0700, true);
exec("rsync -a " . escapeshellarg($storageCerts . '/') . " " . escapeshellarg($destCerts . '/'));
$manifestFiles['storage_certs'] = ['count' => count(glob($destCerts . '/*'))];
}
$astKeys = '/etc/asterisk/keys';
if (is_dir($astKeys)) {
$destAstKeys = $stageDir . '/asterisk_keys';
mkdir($destAstKeys, 0700, true);
exec("rsync -a " . escapeshellarg($astKeys . '/') . " " . escapeshellarg($destAstKeys . '/'));
$manifestFiles['asterisk_keys'] = ['count' => count(glob($destAstKeys . '/*'))];
}
// E. Fichiers Asterisk Custom (.conf)
$destConfs = $stageDir . '/asterisk_custom_conf';
mkdir($destConfs, 0755, true);
foreach (glob('/etc/asterisk/*.custom.conf') as $customConf) {
copy($customConf, $destConfs . '/' . basename($customConf));
}
$manifestFiles['asterisk_custom_conf'] = ['count' => count(glob($destConfs . '/*'))];
// F. Création du Manifeste
$manifest = [
'nexus_version' => self::VERSION,
'created_at' => date('c'),
'timestamp' => time(),
'filename' => $filename,
'source' => $source,
'storage_type' => $settings['backup_storage_type'],
'hostname' => php_uname('n'),
'os' => php_uname('s') . ' ' . php_uname('r'),
'php_version' => PHP_VERSION,
'components' => $manifestFiles,
];
file_put_contents($stageDir . '/manifest.json', json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
// G. Compression en .tar.gz
$tarCmd = "tar -czf " . escapeshellarg($localArchivePath) . " -C " . escapeshellarg($stageDir) . " .";
exec($tarCmd, $tarOut, $tarCode);
if ($tarCode !== 0 || !file_exists($localArchivePath)) {
throw new RuntimeException("Échec de la création de l'archive tar.gz (code {$tarCode}).");
}
@chown($localArchivePath, 'www-data');
@chgrp($localArchivePath, 'asterisk');
@chmod($localArchivePath, 0664);
$fileSize = filesize($localArchivePath);
// 3. Gestion du stockage distant SSH si configuré
$storageType = $settings['backup_storage_type'];
$remoteSuccess = false;
if ($storageType === 'ssh') {
$remotePath = rtrim($settings['backup_ssh_remote_path'], '/');
$transferResult = self::transferToSsh($localArchivePath, $filename, $settings);
if (!$transferResult['success']) {
throw new RuntimeException("Échec du transfert distant SSH : " . $transferResult['message']);
}
$remoteSuccess = true;
// L'utilisateur demande que la sauvegarde distante désactive la sauvegarde locale
// On supprime donc l'archive locale pour ne pas encombrer le disque local
@unlink($localArchivePath);
// Pruning des sauvegardes distantes
self::pruneRemoteBackups($settings);
} else {
// Pruning des sauvegardes locales selon la rétention
self::pruneLocalBackups($settings['backup_retention']);
}
// 4. Mettre à jour le statut dans la DB
$db->prepare("UPDATE settings SET
backup_last_run = :now,
backup_last_status = :status
WHERE id = 1")->execute([
':now' => date('Y-m-d H:i:s'),
':status' => 'Succès (' . ($storageType === 'ssh' ? 'Distant SSH' : 'Local') . ' - ' . self::formatBytes($fileSize) . ')',
]);
return [
'success' => true,
'filename' => $filename,
'storage_type' => $storageType,
'size' => $fileSize,
'size_formatted' => self::formatBytes($fileSize),
'created_at' => date('d/m/Y H:i:s'),
'manifest' => $manifest,
];
} finally {
// Nettoyage impératif du dossier staging
self::removeDirRecursive($stageDir);
}
}
/**
* Liste toutes les sauvegardes disponibles (locales ou distantes).
*/
public static function listBackups(): array
{
self::initDirectory();
$settings = self::getBackupSettings();
$backups = [];
// 1. Sauvegardes locales
$localFiles = glob(self::BACKUP_DIR . '/*.tar.gz') ?: [];
foreach ($localFiles as $file) {
$name = basename($file);
$size = filesize($file);
$mtime = filemtime($file);
// Tenter de lire le manifest depuis l'archive sans l'extraire entièrement
$manifest = self::readManifestFromTar($file);
$backups[] = [
'filename' => $name,
'storage_type' => 'local',
'size' => $size,
'size_formatted' => self::formatBytes($size),
'timestamp' => $mtime,
'date_formatted' => date('d/m/Y H:i:s', $mtime),
'version' => $manifest['nexus_version'] ?? self::VERSION,
'source' => $manifest['source'] ?? 'local',
'is_local' => true,
];
}
// 2. Sauvegardes distantes SSH (si stockage distant SSH configuré)
if ($settings['backup_storage_type'] === 'ssh' && !empty($settings['backup_ssh_host'])) {
$remoteBackups = self::listRemoteBackups($settings);
foreach ($remoteBackups as $rb) {
$backups[] = $rb;
}
}
// Trier par date décroissante (plus récent au plus ancien)
usort($backups, fn($a, $b) => $b['timestamp'] <=> $a['timestamp']);
return $backups;
}
/**
* Supprime une sauvegarde (locale ou distante).
*/
public static function deleteBackup(string $filename): bool
{
$filename = basename($filename);
if ($filename === '' || !str_ends_with($filename, '.tar.gz')) {
throw new InvalidArgumentException("Nom de fichier de sauvegarde invalide.");
}
$localPath = self::BACKUP_DIR . '/' . $filename;
if (file_exists($localPath)) {
return @unlink($localPath);
}
// Si non présent en local, vérifier distant SSH
$settings = self::getBackupSettings();
if ($settings['backup_storage_type'] === 'ssh' && !empty($settings['backup_ssh_host'])) {
$remoteFile = rtrim($settings['backup_ssh_remote_path'], '/') . '/' . $filename;
$res = self::runSshCommand(
$settings['backup_ssh_host'],
$settings['backup_ssh_port'],
$settings['backup_ssh_user'],
$settings['backup_ssh_auth_type'],
$settings['backup_ssh_password'],
"rm -f " . escapeshellarg($remoteFile)
);
return $res['exit_code'] === 0;
}
return false;
}
/**
* Télécharge une sauvegarde (si distante, la rapatrie temporairement pour la streamer).
*/
public static function downloadBackup(string $filename): void
{
$filename = basename($filename);
if ($filename === '' || !str_ends_with($filename, '.tar.gz')) {
http_response_code(400);
die("Nom de sauvegarde invalide.");
}
$localPath = self::BACKUP_DIR . '/' . $filename;
$tempDownload = false;
if (!file_exists($localPath)) {
// Rapatriement depuis SSH
$settings = self::getBackupSettings();
if ($settings['backup_storage_type'] !== 'ssh' || empty($settings['backup_ssh_host'])) {
http_response_code(404);
die("Sauvegarde introuvable.");
}
$localPath = '/tmp/' . uniqid('dl_') . '_' . $filename;
$remoteFile = rtrim($settings['backup_ssh_remote_path'], '/') . '/' . $filename;
$fetchRes = self::fetchFromSsh($remoteFile, $localPath, $settings);
if (!$fetchRes['success'] || !file_exists($localPath)) {
http_response_code(502);
die("Impossible de récupérer la sauvegarde distante : " . $fetchRes['message']);
}
$tempDownload = true;
}
if (ob_get_level()) {
ob_end_clean();
}
header('Content-Description: File Transfer');
header('Content-Type: application/gzip');
header('Content-Disposition: attachment; filename="' . $filename . '"');
header('Expires: 0');
header('Cache-Control: must-revalidate');
header('Pragma: public');
header('Content-Length: ' . filesize($localPath));
readfile($localPath);
if ($tempDownload) {
@unlink($localPath);
}
exit;
}
/**
* Restaure une sauvegarde complète du système PBX en un seul clic !
*
* @param string $archivePath Chemin absolu du fichier .tar.gz (déjà présent ou uploadé)
* @return array
*/
public static function restoreBackup(string $archivePath): array
{
if (!file_exists($archivePath) || !is_readable($archivePath)) {
throw new RuntimeException("Fichier d'archive introuvable ou inaccessible.");
}
// 1. Vérifier la validité de l'archive tar.gz
exec("tar -tzf " . escapeshellarg($archivePath) . " 2>&1", $tarList, $testCode);
if ($testCode !== 0) {
throw new RuntimeException("L'archive fournie est corrompue ou n'est pas un fichier tar.gz valide.");
}
$stageDir = '/tmp/nexus_restore_' . uniqid();
if (!mkdir($stageDir, 0700, true)) {
throw new RuntimeException("Impossible de créer le dossier de décompression.");
}
try {
// 2. Extraction complète dans le dossier de staging
exec("tar -xzf " . escapeshellarg($archivePath) . " -C " . escapeshellarg($stageDir), $extOut, $extCode);
if ($extCode !== 0) {
throw new RuntimeException("Échec de décompression de l'archive de sauvegarde.");
}
// 3. Vérification du manifest et de la base de données SQLite
$manifestPath = $stageDir . '/manifest.json';
$dbPath = $stageDir . '/database.sqlite';
if (!file_exists($dbPath)) {
throw new RuntimeException("L'archive ne contient pas de base de données NexusPBX (database.sqlite). Restauration annulée.");
}
$manifest = [];
if (file_exists($manifestPath)) {
$manifest = json_decode((string)file_get_contents($manifestPath), true) ?: [];
}
// 4. Sécurité : Création d'un backup de précaution de la base actuelle
$currentDb = Database::getDatabasePath();
if (file_exists($currentDb)) {
@copy($currentDb, $currentDb . '.prerestore.' . date('Ymd_His'));
}
// 5. Restauration de la Base de données SQLite
// Remplacer la base SQLite
copy($dbPath, $currentDb);
@chown($currentDb, 'www-data');
@chgrp($currentDb, 'asterisk');
@chmod($currentDb, 0660);
// Supprimer d'éventuels fichiers WAL/SHM résiduels de l'ancienne base
if (file_exists($currentDb . '-wal')) {
@unlink($currentDb . '-wal');
}
if (file_exists($currentDb . '-shm')) {
@unlink($currentDb . '-shm');
}
// 6. Restauration des fichiers sons (NexusPBX & Asterisk)
if (is_dir($stageDir . '/storage_sounds')) {
$destSounds = '/var/www/nexus-pbx/storage/sounds';
@mkdir($destSounds, 0775, true);
exec("rsync -a --delete " . escapeshellarg($stageDir . '/storage_sounds/') . " " . escapeshellarg($destSounds . '/'));
@chown($destSounds, 'www-data');
@chgrp($destSounds, 'asterisk');
exec("chown -R www-data:asterisk " . escapeshellarg($destSounds));
exec("chmod -R 775 " . escapeshellarg($destSounds));
}
if (is_dir($stageDir . '/asterisk_sounds_custom')) {
$destAstSounds = '/var/lib/asterisk/sounds/custom';
@mkdir($destAstSounds, 0775, true);
exec("rsync -a " . escapeshellarg($stageDir . '/asterisk_sounds_custom/') . " " . escapeshellarg($destAstSounds . '/'));
exec("chown -R asterisk:asterisk " . escapeshellarg($destAstSounds));
exec("chmod -R 775 " . escapeshellarg($destAstSounds));
}
// 7. Restauration de la boîte vocale (Voicemail Spool & Greetings)
if (is_dir($stageDir . '/voicemail_default')) {
$destVm = '/var/spool/asterisk/voicemail/default';
@mkdir($destVm, 0775, true);
exec("rsync -a " . escapeshellarg($stageDir . '/voicemail_default/') . " " . escapeshellarg($destVm . '/'));
exec("chown -R asterisk:asterisk /var/spool/asterisk/voicemail");
exec("chmod -R g+rwX /var/spool/asterisk/voicemail");
}
// 8. Restauration des certificats TLS
if (is_dir($stageDir . '/storage_certs')) {
$destCerts = '/var/www/nexus-pbx/storage/certs';
@mkdir($destCerts, 0750, true);
exec("rsync -a " . escapeshellarg($stageDir . '/storage_certs/') . " " . escapeshellarg($destCerts . '/'));
exec("chown -R www-data:asterisk " . escapeshellarg($destCerts));
}
if (is_dir($stageDir . '/asterisk_keys')) {
$destKeys = '/etc/asterisk/keys';
@mkdir($destKeys, 0750, true);
exec("rsync -a " . escapeshellarg($stageDir . '/asterisk_keys/') . " " . escapeshellarg($destKeys . '/'));
exec("chown -R asterisk:asterisk " . escapeshellarg($destKeys));
}
// 9. Restauration des fichiers custom Asterisk (.conf)
if (is_dir($stageDir . '/asterisk_custom_conf')) {
foreach (glob($stageDir . '/asterisk_custom_conf/*.custom.conf') as $cf) {
copy($cf, '/etc/asterisk/' . basename($cf));
@chown('/etc/asterisk/' . basename($cf), 'asterisk');
@chgrp('/etc/asterisk/' . basename($cf), 'asterisk');
@chmod('/etc/asterisk/' . basename($cf), 0664);
}
}
// 10. Régénération intégrale des configurations Asterisk avec la nouvelle base restaurée
Database::resetConnection();
$dbFresh = Database::getConnection(true);
$generator = new ConfigGenerator($dbFresh);
$generator->writeAllConfigs();
// 11. Rechargement du moteur Asterisk via AMI
try {
$ami = AmiClient::fromDatabase($dbFresh);
$reloadRes = $ami->reloadPjsipAndDialplan();
} catch (Exception $e) {
$reloadRes = ['success' => false, 'error' => $e->getMessage()];
}
return [
'success' => true,
'message' => 'Restauration complète effectuée avec succès ! Base de données réintégrée, fichiers audio, messageries vocales et configurations Asterisk rechargées.',
'reload_success' => $reloadRes['success'] ?? false,
'manifest' => $manifest,
];
} finally {
self::removeDirRecursive($stageDir);
}
}
/**
* Exécute le script cron de sauvegarde automatique périodique.
*/
public static function runAutoBackupCron(): array
{
$settings = self::getBackupSettings();
if (empty($settings['backup_auto_enabled'])) {
return ['status' => 'skipped', 'message' => 'Sauvegardes automatiques désactivées.'];
}
$freq = $settings['backup_frequency'];
$lastRun = $settings['backup_last_run'] ? strtotime($settings['backup_last_run']) : 0;
$now = time();
$intervals = [
'hourly' => 3600,
'6h' => 6 * 3600,
'12h' => 12 * 3600,
'daily' => 24 * 3600,
'weekly' => 7 * 24 * 3600,
];
$intervalSeconds = $intervals[$freq] ?? 86400;
// Marge de tolérance de 5 minutes
if (($now - $lastRun) < ($intervalSeconds - 300)) {
return [
'status' => 'skipped',
'message' => "Sauvegarde automatique non requise actuellement (dernière exécutée il y a " . round(($now - $lastRun) / 60) . " min, fréquence: {$freq}).",
];
}
return self::createBackup('cron');
}
// =========================================================================
// MÉTHODES PRIVÉES / UTILITAIRES
// =========================================================================
private static function runSshCommand(string $host, int $port, string $user, string $authType, string $password, string $cmd): array
{
$sshOptions = "-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=8 -p " . (int)$port;
$target = escapeshellarg("{$user}@{$host}");
if ($authType === 'password') {
$fullCmd = "sshpass -p " . escapeshellarg($password) . " ssh {$sshOptions} {$target} " . escapeshellarg($cmd);
} else {
$fullCmd = "ssh {$sshOptions} {$target} " . escapeshellarg($cmd);
}
$descriptors = [
0 => ['pipe', 'r'],
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
];
$proc = proc_open($fullCmd, $descriptors, $pipes);
if (!is_resource($proc)) {
return ['exit_code' => 1, 'output' => '', 'error' => "Impossible de lancer le processus SSH."];
}
fclose($pipes[0]);
$output = stream_get_contents($pipes[1]);
fclose($pipes[1]);
$error = stream_get_contents($pipes[2]);
fclose($pipes[2]);
$exitCode = proc_close($proc);
return [
'exit_code' => $exitCode,
'output' => (string)$output,
'error' => (string)$error,
];
}
private static function transferToSsh(string $localFile, string $filename, array $cfg): array
{
$host = $cfg['backup_ssh_host'];
$port = (int)$cfg['backup_ssh_port'];
$user = $cfg['backup_ssh_user'];
$authType = $cfg['backup_ssh_auth_type'];
$password = $cfg['backup_ssh_password'];
$remotePath = rtrim($cfg['backup_ssh_remote_path'], '/');
// S'assurer que le dossier distant existe
self::runSshCommand($host, $port, $user, $authType, $password, "mkdir -p " . escapeshellarg($remotePath));
$scpOptions = "-P " . (int)$port . " -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=10";
$dest = escapeshellarg("{$user}@{$host}:{$remotePath}/{$filename}");
$src = escapeshellarg($localFile);
if ($authType === 'password') {
$scpCmd = "sshpass -p " . escapeshellarg($password) . " scp {$scpOptions} {$src} {$dest}";
} else {
$scpCmd = "scp {$scpOptions} {$src} {$dest}";
}
exec($scpCmd . " 2>&1", $output, $code);
if ($code !== 0) {
return [
'success' => false,
'message' => implode("\n", $output) ?: "Erreur SCP (code {$code})",
];
}
return ['success' => true];
}
private static function fetchFromSsh(string $remoteFile, string $localDest, array $cfg): array
{
$host = $cfg['backup_ssh_host'];
$port = (int)$cfg['backup_ssh_port'];
$user = $cfg['backup_ssh_user'];
$authType = $cfg['backup_ssh_auth_type'];
$password = $cfg['backup_ssh_password'];
$scpOptions = "-P " . (int)$port . " -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=10";
$src = escapeshellarg("{$user}@{$host}:{$remoteFile}");
$dest = escapeshellarg($localDest);
if ($authType === 'password') {
$scpCmd = "sshpass -p " . escapeshellarg($password) . " scp {$scpOptions} {$src} {$dest}";
} else {
$scpCmd = "scp {$scpOptions} {$src} {$dest}";
}
exec($scpCmd . " 2>&1", $output, $code);
if ($code !== 0) {
return [
'success' => false,
'message' => implode("\n", $output) ?: "Erreur récupération SCP (code {$code})",
];
}
return ['success' => true];
}
private static function listRemoteBackups(array $cfg): array
{
$host = $cfg['backup_ssh_host'];
$port = (int)$cfg['backup_ssh_port'];
$user = $cfg['backup_ssh_user'];
$authType = $cfg['backup_ssh_auth_type'];
$password = $cfg['backup_ssh_password'];
$remotePath = rtrim($cfg['backup_ssh_remote_path'], '/');
$listCmd = "ls -l --time-style=+%s " . escapeshellarg($remotePath) . "/*.tar.gz 2>/dev/null";
$res = self::runSshCommand($host, $port, $user, $authType, $password, $listCmd);
$results = [];
if ($res['exit_code'] === 0 && !empty($res['output'])) {
$lines = explode("\n", trim($res['output']));
foreach ($lines as $line) {
// Format attendu: -rw-r--r-- 1 user group 12345 1698888888 /path/nexus_backup_v1.2.0_20261101_180000.tar.gz
$parts = preg_split('/\s+/', trim($line));
if (count($parts) >= 7) {
$size = (int)$parts[4];
$timestamp = (int)$parts[5];
$fullPath = $parts[6];
$name = basename($fullPath);
if (str_ends_with($name, '.tar.gz')) {
$results[] = [
'filename' => $name,
'storage_type' => 'ssh',
'size' => $size,
'size_formatted' => self::formatBytes($size),
'timestamp' => $timestamp > 0 ? $timestamp : time(),
'date_formatted' => date('d/m/Y H:i:s', $timestamp > 0 ? $timestamp : time()),
'version' => self::VERSION,
'source' => 'distant (SSH)',
'is_local' => false,
];
}
}
}
}
return $results;
}
private static function pruneLocalBackups(int $retention): void
{
if ($retention <= 0) {
return;
}
$files = glob(self::BACKUP_DIR . '/*.tar.gz') ?: [];
if (count($files) <= $retention) {
return;
}
// Trier par date décroissante
usort($files, fn($a, $b) => filemtime($b) <=> filemtime($a));
// Supprimer ceux au-delà du quota de rétention
$toDelete = array_slice($files, $retention);
foreach ($toDelete as $f) {
@unlink($f);
}
}
private static function pruneRemoteBackups(array $cfg): void
{
$retention = (int)($cfg['backup_retention'] ?? 7);
if ($retention <= 0) {
return;
}
$remotePath = rtrim($cfg['backup_ssh_remote_path'], '/');
// Script shell distant pour supprimer les fichiers les plus anciens au-delà de N
$pruneCmd = "cd " . escapeshellarg($remotePath) . " && ls -t *.tar.gz 2>/dev/null | tail -n +" . ($retention + 1) . " | xargs -r rm -f";
self::runSshCommand(
$cfg['backup_ssh_host'],
$cfg['backup_ssh_port'],
$cfg['backup_ssh_user'],
$cfg['backup_ssh_auth_type'],
$cfg['backup_ssh_password'],
$pruneCmd
);
}
private static function readManifestFromTar(string $tarGzPath): ?array
{
// Lire manifest.json sans tout décompresser
$cmd = "tar -xzOf " . escapeshellarg($tarGzPath) . " ./manifest.json 2>/dev/null || tar -xzOf " . escapeshellarg($tarGzPath) . " manifest.json 2>/dev/null";
$json = shell_exec($cmd);
if ($json) {
$parsed = json_decode($json, true);
if (is_array($parsed)) {
return $parsed;
}
}
return null;
}
private static function formatBytes(int $bytes): string
{
if ($bytes >= 1073741824) {
return number_format($bytes / 1073741824, 2) . ' Go';
}
if ($bytes >= 1048576) {
return number_format($bytes / 1048576, 2) . ' Mo';
}
if ($bytes >= 1024) {
return number_format($bytes / 1024, 1) . ' Ko';
}
return $bytes . ' B';
}
private static function removeDirRecursive(string $dir): void
{
if (!is_dir($dir)) {
return;
}
$items = scandir($dir);
if ($items === false) {
return;
}
foreach ($items as $item) {
if ($item === '.' || $item === '..') {
continue;
}
$path = $dir . '/' . $item;
if (is_dir($path)) {
self::removeDirRecursive($path);
} else {
@unlink($path);
}
}
@rmdir($dir);
}
}
+522
View File
@@ -0,0 +1,522 @@
<?php
declare(strict_types=1);
namespace App;
use PDO;
use InvalidArgumentException;
use RuntimeException;
class CertificateManager
{
public const KEYS_BASE_DIR = '/etc/asterisk/keys';
public const WEBROOT_DIR = '/var/www/nexus-pbx/public';
/**
* Récupère la liste de tous les certificats avec recalcul dynamique de l'expiration.
*/
public static function getAll(PDO $db): array
{
$certs = $db->query('SELECT * FROM certificates ORDER BY is_default DESC, id DESC')->fetchAll();
$now = time();
foreach ($certs as &$c) {
$validTo = !empty($c['valid_to']) ? strtotime($c['valid_to']) : null;
if ($validTo) {
$days = (int)round(($validTo - $now) / 86400);
$c['days_remaining'] = $days;
if ($days < 0) {
$c['status'] = 'expired';
} elseif ($days <= 15) {
$c['status'] = 'expiring_soon';
} else {
$c['status'] = 'valid';
}
}
}
unset($c);
return $certs;
}
/**
* Récupère un certificat par son ID.
*/
public static function getById(PDO $db, int $id): ?array
{
$stmt = $db->prepare('SELECT * FROM certificates WHERE id = :id');
$stmt->execute([':id' => $id]);
$cert = $stmt->fetch();
return $cert ?: null;
}
/**
* Récupère le certificat actif par défaut pour Asterisk PJSIP TLS et WebRTC.
*/
public static function getDefault(PDO $db): ?array
{
$stmt = $db->query('SELECT * FROM certificates WHERE is_default = 1 LIMIT 1');
$cert = $stmt->fetch();
if (!$cert) {
$stmt = $db->query('SELECT * FROM certificates ORDER BY id ASC LIMIT 1');
$cert = $stmt->fetch();
}
return $cert ?: null;
}
/**
* Analyse et extrait les métadonnées d'un certificat au format PEM.
*/
public static function parseCertPem(string $certPem): array
{
$certPem = trim($certPem);
if ($certPem === '' || !str_contains($certPem, '-----BEGIN CERTIFICATE-----')) {
throw new InvalidArgumentException("Format de certificat invalide : aucun bloc '-----BEGIN CERTIFICATE-----' trouvé.");
}
$parsed = @openssl_x509_parse($certPem, true);
if ($parsed === false) {
$parsed = @openssl_x509_parse($certPem, false);
}
if ($parsed === false) {
throw new InvalidArgumentException("Impossible d'analyser le certificat X.509 fourni (fichier corrompu ou format non supporté).");
}
$domain = $parsed['subject']['CN'] ?? ($parsed['subject']['commonName'] ?? '');
$altNames = [];
if (!empty($parsed['extensions']['subjectAltName'])) {
$sanRaw = $parsed['extensions']['subjectAltName'];
$parts = explode(',', $sanRaw);
foreach ($parts as $p) {
$p = trim($p);
if (str_starts_with($p, 'DNS:')) {
$altNames[] = substr($p, 4);
} elseif (str_starts_with($p, 'IP Address:')) {
$altNames[] = substr($p, 11);
}
}
}
if ($domain === '' && !empty($altNames)) {
$domain = $altNames[0];
}
$issuer = $parsed['issuer']['O'] ?? ($parsed['issuer']['organizationName'] ?? ($parsed['issuer']['CN'] ?? ($parsed['issuer']['commonName'] ?? 'Inconnu')));
$validFrom = !empty($parsed['validFrom_time_t']) ? date('Y-m-d H:i:s', $parsed['validFrom_time_t']) : null;
$validTo = !empty($parsed['validTo_time_t']) ? date('Y-m-d H:i:s', $parsed['validTo_time_t']) : null;
$daysRemaining = !empty($parsed['validTo_time_t']) ? (int)round(($parsed['validTo_time_t'] - time()) / 86400) : 0;
return [
'domain' => $domain ?: 'domaine-inconnu',
'alt_names' => implode(', ', $altNames),
'issuer' => $issuer,
'valid_from' => $validFrom,
'valid_to' => $validTo,
'days_remaining' => $daysRemaining,
'status' => ($daysRemaining > 15) ? 'valid' : (($daysRemaining >= 0) ? 'expiring_soon' : 'expired'),
];
}
/**
* Valide qu'une clé privée correspond bien au certificat public.
*/
public static function validateKeyPair(string $certPem, string $keyPem, ?string $passphrase = null): bool
{
$certPem = trim($certPem);
$keyPem = trim($keyPem);
if ($keyPem === '' || (!str_contains($keyPem, 'KEY-----') && !str_contains($keyPem, 'BEGIN RSA PRIVATE KEY'))) {
throw new InvalidArgumentException("Format de clé privée invalide : aucun bloc clé privée PEM détecté.");
}
$privKeyResource = openssl_pkey_get_private($keyPem, $passphrase ?? '');
if ($privKeyResource === false) {
throw new InvalidArgumentException("La clé privée est invalide ou chiffrée avec une phrase secrète non renseignée.");
}
$match = openssl_x509_check_private_key($certPem, $privKeyResource);
if (!$match) {
throw new InvalidArgumentException("Incohérence de paire de clés : la clé privée fournie NE CORRESPOND PAS au certificat public.");
}
return true;
}
/**
* Enregistre un certificat importé manuellement (DigiCert, Sectigo, OVH, etc.).
*/
public static function saveCustomCert(
PDO $db,
string $name,
string $certPem,
string $keyPem,
string $chainPem = '',
bool $makeDefault = false
): array {
$meta = self::parseCertPem($certPem);
self::validateKeyPair($certPem, $keyPem);
$name = trim($name);
if ($name === '') {
$name = 'Certificat ' . $meta['domain'];
}
$chainPem = trim($chainPem);
$fullchainPem = ($chainPem !== '') ? ($certPem . "\n" . $chainPem) : $certPem;
// Insertion en base pour obtenir l'ID
$stmt = $db->prepare('INSERT INTO certificates (
name, type, primary_domain, alt_names, issuer, valid_from, valid_to, days_remaining,
cert_path, key_path, chain_path, fullchain_path, email, auto_renew, is_default, status
) VALUES (
:name, :type, :domain, :alt_names, :issuer, :valid_from, :valid_to, :days_rem,
"", "", "", "", :email, 0, 0, :status
)');
$stmt->execute([
':name' => $name,
':type' => 'custom',
':domain' => $meta['domain'],
':alt_names' => $meta['alt_names'],
':issuer' => $meta['issuer'],
':valid_from' => $meta['valid_from'],
':valid_to' => $meta['valid_to'],
':days_rem' => $meta['days_remaining'],
':email' => '',
':status' => $meta['status'],
]);
$certId = (int)$db->lastInsertId();
$targetDir = self::KEYS_BASE_DIR . '/cert_' . $certId;
if (!is_dir($targetDir)) {
mkdir($targetDir, 0770, true);
}
$certPath = $targetDir . '/cert.pem';
$keyPath = $targetDir . '/privkey.pem';
$chainPath = ($chainPem !== '') ? $targetDir . '/chain.pem' : '';
$fullchainPath = $targetDir . '/fullchain.pem';
file_put_contents($certPath, $certPem . "\n");
file_put_contents($keyPath, $keyPem . "\n");
if ($chainPem !== '') {
file_put_contents($chainPath, $chainPem . "\n");
}
file_put_contents($fullchainPath, $fullchainPem . "\n");
self::secureKeyPermissions($targetDir);
$upd = $db->prepare('UPDATE certificates SET
cert_path = :cp,
key_path = :kp,
chain_path = :chp,
fullchain_path = :fcp
WHERE id = :id');
$upd->execute([
':cp' => $certPath,
':kp' => $keyPath,
':chp' => $chainPath,
':fcp' => $fullchainPath,
':id' => $certId,
]);
if ($makeDefault) {
self::setDefault($db, $certId);
}
return self::getById($db, $certId);
}
/**
* Génère un certificat SSL automatique via Let's Encrypt (Certbot HTTP-01 Webroot).
*/
public static function requestLetsEncrypt(
PDO $db,
string $name,
string $domain,
string $email,
bool $makeDefault = true,
bool $staging = false
): array {
$domain = strtolower(trim($domain));
$email = trim($email);
if (!preg_match('/^([a-z0-9]([a-z0-9\-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/i', $domain)) {
throw new InvalidArgumentException("Le nom de domaine '{$domain}' est invalide.");
}
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new InvalidArgumentException("L'adresse email '{$email}' est invalide.");
}
$name = trim($name);
if ($name === '') {
$name = "Let's Encrypt - " . $domain;
}
$challengeDir = self::WEBROOT_DIR . '/.well-known/acme-challenge';
if (!is_dir($challengeDir)) {
mkdir($challengeDir, 0775, true);
}
$escDomain = escapeshellarg($domain);
$escEmail = escapeshellarg($email);
$escWebroot = escapeshellarg(self::WEBROOT_DIR);
$stagingFlag = $staging ? '--test-cert' : '';
// Exécution de Certbot webroot non-interactive
$cmd = "certbot certonly --webroot -w {$escWebroot} -d {$escDomain} --non-interactive --agree-tos -m {$escEmail} --keep-until-expiring {$stagingFlag} 2>&1";
$output = [];
$exitCode = 0;
exec($cmd, $output, $exitCode);
$outputStr = implode("\n", $output);
if ($exitCode !== 0) {
throw new RuntimeException("Échec de la génération Let's Encrypt (Code: {$exitCode}) :\n" . $outputStr);
}
$liveDir = "/etc/letsencrypt/live/{$domain}";
$liveCert = $liveDir . '/cert.pem';
$liveKey = $liveDir . '/privkey.pem';
$liveChain = $liveDir . '/chain.pem';
$liveFullchain = $liveDir . '/fullchain.pem';
if (!file_exists($liveFullchain) || !file_exists($liveKey)) {
throw new RuntimeException("Certbot a terminé mais les fichiers de certificat n'ont pas été trouvés dans {$liveDir}.");
}
$certPem = file_get_contents($liveCert);
$meta = self::parseCertPem($certPem);
// Insertion en base
$stmt = $db->prepare('INSERT INTO certificates (
name, type, primary_domain, alt_names, issuer, valid_from, valid_to, days_remaining,
cert_path, key_path, chain_path, fullchain_path, email, auto_renew, is_default, status
) VALUES (
:name, :type, :domain, :alt_names, :issuer, :valid_from, :valid_to, :days_rem,
"", "", "", "", :email, 1, 0, :status
)');
$stmt->execute([
':name' => $name,
':type' => 'letsencrypt',
':domain' => $domain,
':alt_names' => $meta['alt_names'],
':issuer' => "Let's Encrypt",
':valid_from' => $meta['valid_from'],
':valid_to' => $meta['valid_to'],
':days_rem' => $meta['days_remaining'],
':email' => $email,
':status' => $meta['status'],
]);
$certId = (int)$db->lastInsertId();
$targetDir = self::KEYS_BASE_DIR . '/cert_' . $certId;
if (!is_dir($targetDir)) {
mkdir($targetDir, 0770, true);
}
$certPath = $targetDir . '/cert.pem';
$keyPath = $targetDir . '/privkey.pem';
$chainPath = $targetDir . '/chain.pem';
$fullchainPath = $targetDir . '/fullchain.pem';
copy($liveCert, $certPath);
copy($liveKey, $keyPath);
copy($liveChain, $chainPath);
copy($liveFullchain, $fullchainPath);
self::secureKeyPermissions($targetDir);
$upd = $db->prepare('UPDATE certificates SET
cert_path = :cp,
key_path = :kp,
chain_path = :chp,
fullchain_path = :fcp
WHERE id = :id');
$upd->execute([
':cp' => $certPath,
':kp' => $keyPath,
':chp' => $chainPath,
':fcp' => $fullchainPath,
':id' => $certId,
]);
if ($makeDefault) {
self::setDefault($db, $certId);
}
return self::getById($db, $certId);
}
/**
* Définit le certificat actif par défaut.
*/
public static function setDefault(PDO $db, int $id): bool
{
$cert = self::getById($db, $id);
if (!$cert) {
throw new InvalidArgumentException("Certificat introuvable (ID: {$id}).");
}
$db->beginTransaction();
try {
$db->exec('UPDATE certificates SET is_default = 0');
$stmt = $db->prepare('UPDATE certificates SET is_default = 1 WHERE id = :id');
$stmt->execute([':id' => $id]);
$db->commit();
} catch (\Throwable $e) {
$db->rollBack();
throw $e;
}
// Synchronisation des fichiers actifs pour Nginx et Asterisk
$activeCertFile = self::KEYS_BASE_DIR . '/active_cert.crt';
$activeKeyFile = self::KEYS_BASE_DIR . '/active_key.key';
$sourceCert = !empty($cert['fullchain_path']) && file_exists($cert['fullchain_path'])
? $cert['fullchain_path']
: $cert['cert_path'];
$sourceKey = $cert['key_path'];
if (file_exists($sourceCert) && file_exists($sourceKey)) {
@copy($sourceCert, $activeCertFile);
@copy($sourceKey, $activeKeyFile);
@chmod($activeCertFile, 0640);
@chmod($activeKeyFile, 0640);
@chgrp($activeCertFile, 'asterisk');
@chgrp($activeKeyFile, 'asterisk');
}
return true;
}
/**
* Supprime un certificat.
*/
public static function delete(PDO $db, int $id): bool
{
$cert = self::getById($db, $id);
if (!$cert) {
throw new InvalidArgumentException("Certificat introuvable (ID: {$id}).");
}
$totalCerts = (int)$db->query('SELECT COUNT(*) FROM certificates')->fetchColumn();
if ($totalCerts <= 1) {
throw new RuntimeException("Impossible de supprimer le dernier certificat de la plateforme.");
}
if ((int)$cert['is_default'] === 1) {
throw new RuntimeException("Ce certificat est actuellement actif par défaut. Veuillez d'abord activer un autre certificat.");
}
$targetDir = self::KEYS_BASE_DIR . '/cert_' . $id;
if (is_dir($targetDir)) {
$files = glob($targetDir . '/*');
foreach ($files as $f) {
if (is_file($f)) {
@unlink($f);
}
}
@rmdir($targetDir);
}
$stmt = $db->prepare('DELETE FROM certificates WHERE id = :id');
return $stmt->execute([':id' => $id]);
}
/**
* Renouvelle un certificat Let's Encrypt.
*/
public static function renew(PDO $db, int $id): array
{
$cert = self::getById($db, $id);
if (!$cert) {
throw new InvalidArgumentException("Certificat introuvable (ID: {$id}).");
}
if ($cert['type'] !== 'letsencrypt') {
throw new InvalidArgumentException("Seuls les certificats gérés par Let's Encrypt peuvent être renouvelés automatiquement. Pour les certificats personnalisés, importez la nouvelle version.");
}
$domain = $cert['primary_domain'];
$escDomain = escapeshellarg($domain);
$cmd = "certbot renew --cert-name {$escDomain} --force-renewal 2>&1";
$output = [];
$exitCode = 0;
exec($cmd, $output, $exitCode);
$outputStr = implode("\n", $output);
if ($exitCode !== 0) {
throw new RuntimeException("Échec du renouvellement Certbot (Code: {$exitCode}) :\n" . $outputStr);
}
$liveDir = "/etc/letsencrypt/live/{$domain}";
$liveCert = $liveDir . '/cert.pem';
$liveKey = $liveDir . '/privkey.pem';
$liveChain = $liveDir . '/chain.pem';
$liveFullchain = $liveDir . '/fullchain.pem';
if (file_exists($liveCert)) {
$certPem = file_get_contents($liveCert);
$meta = self::parseCertPem($certPem);
$targetDir = self::KEYS_BASE_DIR . '/cert_' . $id;
if (!is_dir($targetDir)) {
mkdir($targetDir, 0770, true);
}
copy($liveCert, $targetDir . '/cert.pem');
copy($liveKey, $targetDir . '/privkey.pem');
copy($liveChain, $targetDir . '/chain.pem');
copy($liveFullchain, $targetDir . '/fullchain.pem');
self::secureKeyPermissions($targetDir);
$upd = $db->prepare('UPDATE certificates SET
valid_from = :vf,
valid_to = :vt,
days_remaining = :dr,
status = :st,
updated_at = CURRENT_TIMESTAMP
WHERE id = :id');
$upd->execute([
':vf' => $meta['valid_from'],
':vt' => $meta['valid_to'],
':dr' => $meta['days_remaining'],
':st' => $meta['status'],
':id' => $id,
]);
if ((int)$cert['is_default'] === 1) {
self::setDefault($db, $id);
}
}
return self::getById($db, $id);
}
/**
* Applique les permissions sécurisées pour Asterisk.
*/
private static function secureKeyPermissions(string $dir): void
{
if (!is_dir($dir)) {
return;
}
@chmod($dir, 0770);
@chgrp($dir, 'asterisk');
$files = glob($dir . '/*');
foreach ($files as $f) {
if (is_file($f)) {
@chmod($f, 0640);
@chgrp($f, 'asterisk');
}
}
}
}
File diff suppressed because it is too large. Load diff
+871
View File
@@ -0,0 +1,871 @@
<?php
declare(strict_types=1);
namespace App;
use PDO;
class Database
{
private static ?PDO $instance = null;
public const DB_PATH = __DIR__ . '/../storage/database.sqlite';
public static function getDatabasePath(): string
{
return self::DB_PATH;
}
public static function resetConnection(): void
{
self::$instance = null;
}
public static function getConnection(bool $forceReconnect = false): PDO
{
if (self::$instance === null || $forceReconnect) {
self::$instance = null;
$dir = dirname(self::DB_PATH);
if (!is_dir($dir)) {
mkdir($dir, 0770, true);
}
$isNew = !file_exists(self::DB_PATH);
self::$instance = new PDO('sqlite:' . self::DB_PATH, null, null, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
self::$instance->exec('PRAGMA foreign_keys = ON;');
self::$instance->exec('PRAGMA journal_mode = WAL;');
self::$instance->exec('PRAGMA busy_timeout = 5000;');
self::initializeSchema();
if ($isNew) {
@chmod(self::DB_PATH, 0660);
}
}
return self::$instance;
}
public static function initializeSchema(): void
{
$db = self::$instance;
if (!$db) {
return;
}
$schema = <<<SQL
-- 1. Table settings : Paramètres globaux du serveur Asterisk & AMI
CREATE TABLE IF NOT EXISTS settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
domain TEXT NOT NULL DEFAULT 'pbx.local',
external_ip TEXT NOT NULL DEFAULT '',
local_net TEXT NOT NULL DEFAULT '192.168.1.0/24',
udp_port INTEGER NOT NULL DEFAULT 5060,
tls_port INTEGER NOT NULL DEFAULT 5061,
letsencrypt_domain TEXT NOT NULL DEFAULT '',
ami_host TEXT NOT NULL DEFAULT '127.0.0.1',
ami_port INTEGER NOT NULL DEFAULT 5038,
ami_user TEXT NOT NULL DEFAULT 'pbxgui',
ami_secret TEXT NOT NULL DEFAULT 'AsteriskAmi2026!',
wizard_completed INTEGER NOT NULL DEFAULT 0,
tls_verify_client INTEGER NOT NULL DEFAULT 0,
external_udp_port INTEGER NOT NULL DEFAULT 0,
external_tls_port INTEGER NOT NULL DEFAULT 0,
local_networks_json TEXT NOT NULL DEFAULT '["10.10.0.0/16","192.168.1.0/24"]',
rtp_start INTEGER NOT NULL DEFAULT 10000,
rtp_end INTEGER NOT NULL DEFAULT 20000,
rtp_checksums INTEGER NOT NULL DEFAULT 1,
rtp_strict INTEGER NOT NULL DEFAULT 1,
rtp_timeout INTEGER NOT NULL DEFAULT 30,
rtp_keepalive INTEGER NOT NULL DEFAULT 15,
allow_anonymous_sip INTEGER NOT NULL DEFAULT 0,
last_applied_at DATETIME DEFAULT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
INSERT OR IGNORE INTO settings (id) VALUES (1);
-- 2. Table users : Authentification sur l'interface d'administration
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username VARCHAR(64) NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- 3. Table extensions : Postes téléphoniques PJSIP (UDP interne ou TLS+SRTP distant)
CREATE TABLE IF NOT EXISTS extensions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
extension VARCHAR(16) NOT NULL UNIQUE,
name VARCHAR(64) NOT NULL,
secret VARCHAR(128) NOT NULL,
transport VARCHAR(8) NOT NULL DEFAULT 'udp' CHECK (transport IN ('udp', 'tls')),
encryption INTEGER NOT NULL DEFAULT 0 CHECK (encryption IN (0, 1, 2)),
codecs VARCHAR(64) NOT NULL DEFAULT 'alaw,ulaw,opus',
max_contacts INTEGER NOT NULL DEFAULT 2,
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- 4. Table groups : Groupes d'appel (Ring Groups)
CREATE TABLE IF NOT EXISTS groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
extension VARCHAR(16) NOT NULL UNIQUE,
name VARCHAR(64) NOT NULL,
strategy VARCHAR(16) NOT NULL DEFAULT 'ringall' CHECK (strategy IN ('ringall', 'hunt', 'random')),
ring_time INTEGER NOT NULL DEFAULT 20,
members TEXT NOT NULL DEFAULT '[]',
failover_dest VARCHAR(64) NOT NULL DEFAULT 'hangup',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- 5. Table trunks : Opérateurs SIP entrants / sortants (Configuration PJSIP complète)
CREATE TABLE IF NOT EXISTS trunks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(64) NOT NULL UNIQUE,
host VARCHAR(128) NOT NULL,
port INTEGER NOT NULL DEFAULT 5060,
outbound_proxy VARCHAR(128) NOT NULL DEFAULT '',
transport VARCHAR(8) NOT NULL DEFAULT 'udp',
auth_mode VARCHAR(16) NOT NULL DEFAULT 'userpass',
username VARCHAR(64) NOT NULL DEFAULT '',
auth_username VARCHAR(64) NOT NULL DEFAULT '',
secret VARCHAR(128) NOT NULL DEFAULT '',
register INTEGER NOT NULL DEFAULT 1 CHECK (register IN (0, 1)),
server_uri VARCHAR(192) NOT NULL DEFAULT '',
client_uri VARCHAR(192) NOT NULL DEFAULT '',
contact_user VARCHAR(64) NOT NULL DEFAULT '',
expiration INTEGER NOT NULL DEFAULT 3600,
retry_interval INTEGER NOT NULL DEFAULT 60,
line_support INTEGER NOT NULL DEFAULT 1 CHECK (line_support IN (0, 1)),
from_user VARCHAR(64) NOT NULL DEFAULT '',
from_domain VARCHAR(128) NOT NULL DEFAULT '',
outbound_callerid VARCHAR(128) NOT NULL DEFAULT '',
send_pai INTEGER NOT NULL DEFAULT 1 CHECK (send_pai IN (0, 1)),
send_rpid INTEGER NOT NULL DEFAULT 0 CHECK (send_rpid IN (0, 1)),
trust_id_inbound INTEGER NOT NULL DEFAULT 1 CHECK (trust_id_inbound IN (0, 1)),
codecs VARCHAR(64) NOT NULL DEFAULT 'alaw,ulaw,g722',
dtmf_mode VARCHAR(16) NOT NULL DEFAULT 'rfc4733',
media_encryption VARCHAR(16) NOT NULL DEFAULT 'no',
nat_symmetric INTEGER NOT NULL DEFAULT 1 CHECK (nat_symmetric IN (0, 1)),
direct_media INTEGER NOT NULL DEFAULT 0 CHECK (direct_media IN (0, 1)),
qualify_frequency INTEGER NOT NULL DEFAULT 60,
max_channels INTEGER NOT NULL DEFAULT 0,
match_ips TEXT NOT NULL DEFAULT '',
context VARCHAR(64) NOT NULL DEFAULT 'from-external',
inbound_dest VARCHAR(64) NOT NULL DEFAULT 'ivr-accueil',
did_routes_json TEXT NOT NULL DEFAULT '{}',
outbound_prefix VARCHAR(32) NOT NULL DEFAULT '',
custom_endpoint_params TEXT NOT NULL DEFAULT '',
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- 6. Table ivrs : Serveurs vocaux interactifs (SVI / IVR)
CREATE TABLE IF NOT EXISTS ivrs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(64) NOT NULL UNIQUE,
audio_file VARCHAR(128) NOT NULL DEFAULT 'hello-world',
timeout INTEGER NOT NULL DEFAULT 5,
options_json TEXT NOT NULL DEFAULT '{}',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- 7. Table outbound_routes : Routes Sortantes (Priorité, Séquence Trunks, Dial Patterns, Droits Groupes & Postes)
CREATE TABLE IF NOT EXISTS outbound_routes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
priority INTEGER NOT NULL DEFAULT 10,
name VARCHAR(64) NOT NULL,
outbound_callerid VARCHAR(128) NOT NULL DEFAULT '',
override_ext_cid INTEGER NOT NULL DEFAULT 0 CHECK (override_ext_cid IN (0, 1)),
route_password VARCHAR(64) NOT NULL DEFAULT '',
route_type VARCHAR(32) NOT NULL DEFAULT 'normal',
moh_class VARCHAR(64) NOT NULL DEFAULT 'default',
trunk_sequence_json TEXT NOT NULL DEFAULT '[]',
dial_patterns_json TEXT NOT NULL DEFAULT '[]',
permission_mode VARCHAR(16) NOT NULL DEFAULT 'all',
allowed_groups_json TEXT NOT NULL DEFAULT '[]',
allowed_extensions_json TEXT NOT NULL DEFAULT '[]',
continue_next_route INTEGER NOT NULL DEFAULT 1 CHECK (continue_next_route IN (0, 1)),
congestion_dest VARCHAR(64) NOT NULL DEFAULT 'terminate-congestion',
record_mode VARCHAR(16) NOT NULL DEFAULT 'dontcare',
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- 8. Table extension_groups : Groupes d'extensions (Sites, Services, Droits Routes Sortantes - ex: Nice, Paris)
CREATE TABLE IF NOT EXISTS extension_groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(64) NOT NULL UNIQUE,
description VARCHAR(128) NOT NULL DEFAULT '',
color VARCHAR(32) NOT NULL DEFAULT 'cyan',
members TEXT NOT NULL DEFAULT '[]',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- 9. Table sounds : Médiathèque & Fichiers Sons (MoH, annonces, IVR, congestion classés par site et usage)
CREATE TABLE IF NOT EXISTS sounds (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(128) NOT NULL,
slug VARCHAR(128) NOT NULL UNIQUE,
site_category VARCHAR(64) NOT NULL DEFAULT 'Global',
usage_type VARCHAR(32) NOT NULL DEFAULT 'announcement',
description VARCHAR(255) NOT NULL DEFAULT '',
filename VARCHAR(255) NOT NULL,
original_filename VARCHAR(255) NOT NULL DEFAULT '',
duration_sec INTEGER NOT NULL DEFAULT 0,
file_size INTEGER NOT NULL DEFAULT 0,
format VARCHAR(16) NOT NULL DEFAULT 'wav',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- 10. Table sound_sites : Catégories de sites pour les sons (Global, Site Nice, Site Paris...)
CREATE TABLE IF NOT EXISTS sound_sites (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(64) NOT NULL UNIQUE,
description VARCHAR(255) NOT NULL DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- 11. Table certificates : Gestionnaire de Certificats SSL / TLS (Let's Encrypt & Custom)
CREATE TABLE IF NOT EXISTS certificates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(128) NOT NULL,
type VARCHAR(32) NOT NULL DEFAULT 'custom', -- letsencrypt, custom, selfsigned
primary_domain VARCHAR(255) NOT NULL,
alt_names TEXT NOT NULL DEFAULT '',
issuer VARCHAR(255) NOT NULL DEFAULT '',
valid_from DATETIME DEFAULT NULL,
valid_to DATETIME DEFAULT NULL,
days_remaining INTEGER NOT NULL DEFAULT 0,
cert_path TEXT NOT NULL,
key_path TEXT NOT NULL,
chain_path TEXT NOT NULL DEFAULT '',
fullchain_path TEXT NOT NULL DEFAULT '',
email VARCHAR(255) NOT NULL DEFAULT '',
auto_renew INTEGER NOT NULL DEFAULT 1,
is_default INTEGER NOT NULL DEFAULT 0,
status VARCHAR(32) NOT NULL DEFAULT 'valid', -- valid, expiring_soon, expired, error
last_error TEXT NOT NULL DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- 12. Table feature_codes : Codes de Fonctions Téléphoniques & Services Asterisk
CREATE TABLE IF NOT EXISTS feature_codes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
feature_key VARCHAR(64) UNIQUE NOT NULL,
code VARCHAR(16) NOT NULL,
default_code VARCHAR(16) NOT NULL,
name VARCHAR(128) NOT NULL,
category VARCHAR(64) NOT NULL,
description TEXT NOT NULL DEFAULT '',
enabled INTEGER NOT NULL DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
SQL;
$db->exec($schema);
self::migrateSettingsTable($db);
self::migrateExtensionsTable($db);
self::migrateTrunksTable($db);
self::migrateOutboundRoutesTable($db);
self::migrateExtensionGroupsTable($db);
self::migrateSoundsTable($db);
self::migrateSoundSitesTable($db);
self::migrateIvrsTable($db);
self::migrateCertificatesTable($db);
self::migrateFeatureCodesTable($db);
// Création du compte admin par défaut (admin / admin123) s'il n'existe aucun utilisateur
$userCount = (int)$db->query('SELECT COUNT(*) FROM users')->fetchColumn();
if ($userCount === 0) {
$stmt = $db->prepare('INSERT INTO users (username, password_hash) VALUES (:u, :p)');
$stmt->execute([
':u' => 'admin',
':p' => password_hash('admin123', PASSWORD_DEFAULT),
]);
}
// Données de démonstration initiales si la table extensions est vide
$extCount = (int)$db->query('SELECT COUNT(*) FROM extensions')->fetchColumn();
if ($extCount === 0) {
$stmtExt = $db->prepare('
INSERT INTO extensions (extension, name, secret, transport, encryption, codecs, max_contacts, enabled)
VALUES (:ext, :name, :secret, :transport, :encryption, :codecs, :max_contacts, 1)
');
$stmtExt->execute([
':ext' => '101',
':name' => 'Accueil Bureau (LAN)',
':secret' => 'LanPass101!Sip',
':transport' => 'udp',
':encryption' => 0,
':codecs' => 'alaw,ulaw,g722',
':max_contacts' => 2,
]);
$stmtExt->execute([
':ext' => '102',
':name' => 'Direction Nomade (TLS/SRTP)',
':secret' => 'TlsSecure102!Sip',
':transport' => 'tls',
':encryption' => 1,
':codecs' => 'opus,g722,alaw',
':max_contacts' => 3,
]);
$stmtGrp = $db->prepare('
INSERT INTO groups (extension, name, strategy, ring_time, members, failover_dest)
VALUES (:ext, :name, :strategy, :ring_time, :members, :failover)
');
$stmtGrp->execute([
':ext' => '600',
':name' => 'Support Général',
':strategy' => 'ringall',
':ring_time' => 25,
':members' => json_encode(['101', '102']),
':failover' => 'hangup',
]);
$stmtIvr = $db->prepare('
INSERT INTO ivrs (name, audio_file, timeout, options_json)
VALUES (:name, :audio, :timeout, :options)
');
$stmtIvr->execute([
':name' => 'ivr-accueil',
':audio' => 'custom/bienvenue-entreprise',
':timeout' => 5,
':options' => json_encode([
'1' => 'ext-101',
'2' => 'grp-600',
'0' => 'ext-102',
]),
]);
}
}
private static function migrateSettingsTable(PDO $db): void
{
$cols = $db->query('PRAGMA table_info(settings)')->fetchAll();
$existing = array_column($cols, 'name');
if (!in_array('company_name', $existing, true)) {
$db->exec("ALTER TABLE settings ADD COLUMN company_name VARCHAR(128) NOT NULL DEFAULT ''");
}
if (!in_array('company_logo', $existing, true)) {
$db->exec("ALTER TABLE settings ADD COLUMN company_logo TEXT NOT NULL DEFAULT ''");
}
if (!in_array('default_moh_sound_id', $existing, true)) {
$db->exec("ALTER TABLE settings ADD COLUMN default_moh_sound_id INTEGER DEFAULT 2");
}
if (!in_array('tls_verify_client', $existing, true)) {
$db->exec("ALTER TABLE settings ADD COLUMN tls_verify_client INTEGER NOT NULL DEFAULT 0");
}
$extraSettings = [
'external_udp_port' => "INTEGER NOT NULL DEFAULT 0",
'external_tls_port' => "INTEGER NOT NULL DEFAULT 0",
'local_networks_json' => "TEXT NOT NULL DEFAULT '[\"10.10.0.0/16\",\"192.168.1.0/24\"]'",
'rtp_start' => "INTEGER NOT NULL DEFAULT 10000",
'rtp_end' => "INTEGER NOT NULL DEFAULT 20000",
'rtp_checksums' => "INTEGER NOT NULL DEFAULT 1",
'rtp_strict' => "INTEGER NOT NULL DEFAULT 1",
'rtp_timeout' => "INTEGER NOT NULL DEFAULT 30",
'rtp_keepalive' => "INTEGER NOT NULL DEFAULT 15",
'allow_anonymous_sip' => "INTEGER NOT NULL DEFAULT 0",
'system_language' => "VARCHAR(16) NOT NULL DEFAULT 'fr'",
'backup_auto_enabled' => "INTEGER NOT NULL DEFAULT 0",
'backup_frequency' => "VARCHAR(32) NOT NULL DEFAULT 'daily'",
'backup_retention' => "INTEGER NOT NULL DEFAULT 7",
'backup_storage_type' => "VARCHAR(16) NOT NULL DEFAULT 'local'",
'backup_ssh_host' => "VARCHAR(128) NOT NULL DEFAULT ''",
'backup_ssh_port' => "INTEGER NOT NULL DEFAULT 22",
'backup_ssh_user' => "VARCHAR(64) NOT NULL DEFAULT ''",
'backup_ssh_auth_type'=> "VARCHAR(16) NOT NULL DEFAULT 'password'",
'backup_ssh_password' => "TEXT NOT NULL DEFAULT ''",
'backup_ssh_remote_path' => "VARCHAR(255) NOT NULL DEFAULT '/backups/nexus'",
'backup_last_run' => "VARCHAR(32) DEFAULT NULL",
'backup_last_status' => "TEXT NOT NULL DEFAULT ''",
'firewall_enabled' => "INTEGER NOT NULL DEFAULT 1",
'admin_https_port' => "INTEGER NOT NULL DEFAULT 8443",
'certbot_strict_port80' => "INTEGER NOT NULL DEFAULT 0",
'fail2ban_sip_maxretry' => "INTEGER NOT NULL DEFAULT 3",
'fail2ban_sip_findtime' => "INTEGER NOT NULL DEFAULT 600",
'fail2ban_sip_bantime' => "INTEGER NOT NULL DEFAULT 3600",
'fail2ban_web_maxretry' => "INTEGER NOT NULL DEFAULT 3",
'fail2ban_web_bantime' => "INTEGER NOT NULL DEFAULT 3600",
'fail2ban_whitelist_ips' => "TEXT NOT NULL DEFAULT '127.0.0.1/8 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 82.122.84.60'",
];
foreach ($extraSettings as $colName => $colDef) {
if (!in_array($colName, $existing, true)) {
$db->exec("ALTER TABLE settings ADD COLUMN {$colName} {$colDef}");
}
}
}
private static function migrateExtensionsTable(PDO $db): void
{
$sql = $db->query("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'extensions'")->fetchColumn();
if ($sql && str_contains($sql, "CHECK (encryption IN (0, 1))")) {
$db->beginTransaction();
try {
$db->exec("CREATE TABLE extensions_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
extension VARCHAR(16) NOT NULL UNIQUE,
name VARCHAR(64) NOT NULL,
secret VARCHAR(128) NOT NULL,
transport VARCHAR(8) NOT NULL DEFAULT 'udp' CHECK (transport IN ('udp', 'tls')),
encryption INTEGER NOT NULL DEFAULT 0 CHECK (encryption IN (0, 1, 2)),
codecs VARCHAR(64) NOT NULL DEFAULT 'alaw,ulaw,opus',
max_contacts INTEGER NOT NULL DEFAULT 2,
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)");
$db->exec("INSERT INTO extensions_new SELECT * FROM extensions");
$db->exec("DROP TABLE extensions");
$db->exec("ALTER TABLE extensions_new RENAME TO extensions");
$db->commit();
} catch (\Throwable $e) {
$db->rollBack();
throw $e;
}
}
$cols = $db->query('PRAGMA table_info(extensions)')->fetchAll();
$existing = array_column($cols, 'name');
$extraExtensionCols = [
'voicemail_enabled' => "INTEGER NOT NULL DEFAULT 0",
'voicemail_pin' => "VARCHAR(32) NOT NULL DEFAULT ''",
'force_pin_change' => "INTEGER NOT NULL DEFAULT 0",
'voicemail_skip_pin' => "INTEGER NOT NULL DEFAULT 0",
'voicemail_email' => "VARCHAR(128) NOT NULL DEFAULT ''",
'voicemail_attach' => "INTEGER NOT NULL DEFAULT 1",
'voicemail_delete' => "INTEGER NOT NULL DEFAULT 0",
'ring_time' => "INTEGER NOT NULL DEFAULT 20",
];
foreach ($extraExtensionCols as $colName => $colDef) {
if (!in_array($colName, $existing, true)) {
$db->exec("ALTER TABLE extensions ADD COLUMN {$colName} {$colDef}");
}
}
}
private static function migrateTrunksTable(PDO $db): void
{
$cols = $db->query('PRAGMA table_info(trunks)')->fetchAll();
$existing = array_column($cols, 'name');
if (!in_array('outbound_proxy', $existing, true)) {
// Migration sans perte de la table trunks vers le schéma PJSIP complet (avec support UDP/TCP/TLS)
$db->beginTransaction();
try {
$db->exec('ALTER TABLE trunks RENAME TO trunks_old');
$db->exec("
CREATE TABLE trunks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(64) NOT NULL UNIQUE,
host VARCHAR(128) NOT NULL,
port INTEGER NOT NULL DEFAULT 5060,
outbound_proxy VARCHAR(128) NOT NULL DEFAULT '',
transport VARCHAR(8) NOT NULL DEFAULT 'udp',
auth_mode VARCHAR(16) NOT NULL DEFAULT 'userpass',
username VARCHAR(64) NOT NULL DEFAULT '',
auth_username VARCHAR(64) NOT NULL DEFAULT '',
secret VARCHAR(128) NOT NULL DEFAULT '',
register INTEGER NOT NULL DEFAULT 1 CHECK (register IN (0, 1)),
server_uri VARCHAR(192) NOT NULL DEFAULT '',
client_uri VARCHAR(192) NOT NULL DEFAULT '',
contact_user VARCHAR(64) NOT NULL DEFAULT '',
expiration INTEGER NOT NULL DEFAULT 3600,
retry_interval INTEGER NOT NULL DEFAULT 60,
line_support INTEGER NOT NULL DEFAULT 1 CHECK (line_support IN (0, 1)),
from_user VARCHAR(64) NOT NULL DEFAULT '',
from_domain VARCHAR(128) NOT NULL DEFAULT '',
outbound_callerid VARCHAR(128) NOT NULL DEFAULT '',
send_pai INTEGER NOT NULL DEFAULT 1 CHECK (send_pai IN (0, 1)),
send_rpid INTEGER NOT NULL DEFAULT 0 CHECK (send_rpid IN (0, 1)),
trust_id_inbound INTEGER NOT NULL DEFAULT 1 CHECK (trust_id_inbound IN (0, 1)),
codecs VARCHAR(64) NOT NULL DEFAULT 'alaw,ulaw,g722',
dtmf_mode VARCHAR(16) NOT NULL DEFAULT 'rfc4733',
media_encryption VARCHAR(16) NOT NULL DEFAULT 'no',
nat_symmetric INTEGER NOT NULL DEFAULT 1 CHECK (nat_symmetric IN (0, 1)),
direct_media INTEGER NOT NULL DEFAULT 0 CHECK (direct_media IN (0, 1)),
qualify_frequency INTEGER NOT NULL DEFAULT 60,
max_channels INTEGER NOT NULL DEFAULT 0,
match_ips TEXT NOT NULL DEFAULT '',
context VARCHAR(64) NOT NULL DEFAULT 'from-external',
inbound_dest VARCHAR(64) NOT NULL DEFAULT 'ivr-accueil',
did_routes_json TEXT NOT NULL DEFAULT '{}',
outbound_prefix VARCHAR(32) NOT NULL DEFAULT '',
custom_endpoint_params TEXT NOT NULL DEFAULT '',
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
");
$db->exec("
INSERT INTO trunks (id, name, host, port, username, secret, transport, register, codecs, inbound_dest, enabled, created_at)
SELECT id, name, host, port, username, secret, transport, register, codecs, inbound_dest, enabled, created_at
FROM trunks_old
");
$db->exec('DROP TABLE trunks_old');
$db->commit();
} catch (\Throwable $e) {
$db->rollBack();
throw $e;
}
$cols = $db->query('PRAGMA table_info(trunks)')->fetchAll();
$existing = array_column($cols, 'name');
}
// Colonnes universelles PJSIP supplémentaires (Session Timers, Fax T.38, Diversion, Retries, Langue, Keepalive)
$extraColumns = [
'max_retries' => "INTEGER NOT NULL DEFAULT 10000",
'forbidden_retry_interval' => "INTEGER NOT NULL DEFAULT 600",
'fatal_retry_interval' => "INTEGER NOT NULL DEFAULT 0",
'send_diversion' => "INTEGER NOT NULL DEFAULT 1",
'timers' => "VARCHAR(16) NOT NULL DEFAULT 'yes'",
'language' => "VARCHAR(16) NOT NULL DEFAULT 'fr'",
'rtp_keepalive' => "INTEGER NOT NULL DEFAULT 15",
't38_udptl' => "VARCHAR(16) NOT NULL DEFAULT 'no'",
'fax_detect' => "INTEGER NOT NULL DEFAULT 0",
'inband_progress' => "INTEGER NOT NULL DEFAULT 0",
'custom_reg_params' => "TEXT NOT NULL DEFAULT ''",
'custom_aor_params' => "TEXT NOT NULL DEFAULT ''",
'moh_sound_id' => "INTEGER NOT NULL DEFAULT 0",
];
foreach ($extraColumns as $colName => $colDef) {
if (!in_array($colName, $existing, true)) {
$db->exec("ALTER TABLE trunks ADD COLUMN {$colName} {$colDef}");
}
}
}
private static function migrateOutboundRoutesTable(PDO $db): void
{
$count = (int)$db->query('SELECT COUNT(*) FROM outbound_routes')->fetchColumn();
if ($count === 0) {
// Vérifie quels trunks et postes existent déjà pour pré-initialiser la route sortante principale
$trunks = $db->query('SELECT name, outbound_callerid FROM trunks ORDER BY id ASC')->fetchAll();
$firstTrunkName = !empty($trunks) ? (string)$trunks[0]['name'] : 'ovh-telecom';
$firstTrunkCid = !empty($trunks) && trim((string)$trunks[0]['outbound_callerid']) !== ''
? trim((string)$trunks[0]['outbound_callerid'])
: 'Sytelis Consulting <0422560185>';
$exts = $db->query('SELECT extension FROM extensions ORDER BY CAST(extension AS INTEGER) ASC')->fetchAll(PDO::FETCH_COLUMN);
$grps = $db->query('SELECT extension FROM groups ORDER BY CAST(extension AS INTEGER) ASC')->fetchAll(PDO::FETCH_COLUMN);
$stmt = $db->prepare('
INSERT INTO outbound_routes (
priority, name, outbound_callerid, override_ext_cid, route_password,
route_type, moh_class, trunk_sequence_json, dial_patterns_json,
permission_mode, allowed_groups_json, allowed_extensions_json,
continue_next_route, congestion_dest, record_mode, enabled
) VALUES (
:priority, :name, :cid, :override_cid, :pwd,
:rtype, :moh, :trunks, :patterns,
:pmode, :agroups, :aexts,
:cont, :cdest, :rec, 1
)
');
// Route 1 : OVH (principale)
$stmt->execute([
':priority' => 1,
':name' => 'OVH',
':cid' => $firstTrunkCid,
':override_cid' => 0,
':pwd' => '',
':rtype' => 'normal',
':moh' => 'default',
':trunks' => json_encode([$firstTrunkName]),
':patterns' => json_encode([
['prepend' => '', 'prefix' => '', 'match_pattern' => '0[1-59]XXXXXXXX', 'callerid' => ''],
['prepend' => '', 'prefix' => '', 'match_pattern' => '0[67]XXXXXXXX', 'callerid' => ''],
['prepend' => '', 'prefix' => '', 'match_pattern' => '080[0-9]XXXXXX', 'callerid' => ''],
]),
':pmode' => 'all',
':agroups' => json_encode($grps),
':aexts' => json_encode($exts),
':cont' => 1,
':cdest' => 'terminate-busy',
':rec' => 'dontcare',
]);
// Route 2 : Emergency (Numéros d'urgence)
$stmt->execute([
':priority' => 2,
':name' => 'Emergency',
':cid' => $firstTrunkCid,
':override_cid' => 1,
':pwd' => '',
':rtype' => 'emergency',
':moh' => 'default',
':trunks' => json_encode([$firstTrunkName]),
':patterns' => json_encode([
['prepend' => '', 'prefix' => '', 'match_pattern' => '15', 'callerid' => ''],
['prepend' => '', 'prefix' => '', 'match_pattern' => '17', 'callerid' => ''],
['prepend' => '', 'prefix' => '', 'match_pattern' => '18', 'callerid' => ''],
['prepend' => '', 'prefix' => '', 'match_pattern' => '112', 'callerid' => ''],
['prepend' => '', 'prefix' => '', 'match_pattern' => '114', 'callerid' => ''],
]),
':pmode' => 'all',
':agroups' => json_encode($grps),
':aexts' => json_encode($exts),
':cont' => 1,
':cdest' => 'terminate-congestion',
':rec' => 'dontcare',
]);
}
}
private static function migrateExtensionGroupsTable(PDO $db): void
{
$count = (int)$db->query('SELECT COUNT(*) FROM extension_groups')->fetchColumn();
if ($count === 0) {
$stmt = $db->prepare('INSERT INTO extension_groups (name, description, color, members) VALUES (:n, :d, :c, :m)');
$stmt->execute([
':n' => 'Groupe Nice',
':d' => 'Extensions du site de Nice (Trunk local Livebox)',
':c' => 'emerald',
':m' => json_encode(['101', '102']),
]);
$stmt->execute([
':n' => 'Groupe Paris',
':d' => 'Extensions du site de Paris (Trunk principal OVH)',
':c' => 'indigo',
':m' => json_encode(['103']),
]);
}
}
private static function migrateSoundsTable(PDO $db): void
{
$count = (int)$db->query('SELECT COUNT(*) FROM sounds')->fetchColumn();
if ($count === 0) {
$stmt = $db->prepare('
INSERT INTO sounds (name, slug, site_category, usage_type, description, filename, original_filename, duration_sec, file_size, format)
VALUES (:name, :slug, :site, :usage, :desc, :file, :orig, :dur, :size, :fmt)
');
$demoSounds = [
[
':name' => 'Message d\'accueil SVI Standard',
':slug' => 'bienvenue-entreprise',
':site' => 'Global',
':usage' => 'ivr',
':desc' => 'Jingle d\'accueil principal du serveur vocal interactif',
':file' => 'bienvenue-entreprise.wav',
':orig' => 'bienvenue-entreprise.wav',
':dur' => 2,
':size' => 31800,
':fmt' => 'wav',
],
[
':name' => 'Musique d\'attente Standard',
':slug' => 'attente-musicale-standard',
':site' => 'Global',
':usage' => 'moh',
':desc' => 'Mélodie douce d\'attente (MoH) par défaut pour les appels mis en garde',
':file' => 'attente-musicale-standard.wav',
':orig' => 'attente-musicale-standard.wav',
':dur' => 4,
':size' => 63600,
':fmt' => 'wav',
],
[
':name' => 'Message Ligne Occupée & Congestion',
':slug' => 'ligne-occupee-congestion',
':site' => 'Global',
':usage' => 'congestion',
':desc' => 'Tonalité cadencée d\'occupation si aucun trunk ou poste n\'est disponible',
':file' => 'ligne-occupee-congestion.wav',
':orig' => 'ligne-occupee-congestion.wav',
':dur' => 1,
':size' => 16000,
':fmt' => 'wav',
],
[
':name' => 'Annonce Fermeture Nice',
':slug' => 'annonce-fermeture-nice',
':site' => 'Site Nice',
':usage' => 'announcement',
':desc' => 'Message informant des horaires d\'ouverture du bureau de Nice',
':file' => 'annonce-fermeture-nice.wav',
':orig' => 'annonce-fermeture-nice.wav',
':dur' => 2,
':size' => 28800,
':fmt' => 'wav',
],
];
foreach ($demoSounds as $ds) {
$stmt->execute($ds);
}
}
}
private static function migrateSoundSitesTable(PDO $db): void
{
$count = (int)$db->query('SELECT COUNT(*) FROM sound_sites')->fetchColumn();
if ($count === 0) {
$stmt = $db->prepare('INSERT INTO sound_sites (name, description) VALUES (:n, :d)');
$stmt->execute([':n' => 'Global', ':d' => 'Fichiers généraux partagés par tout le PBX']);
$stmt->execute([':n' => 'Site Nice', ':d' => 'Annonces et musiques spécifiques au site de Nice']);
$stmt->execute([':n' => 'Site Paris', ':d' => 'Annonces et musiques spécifiques au site de Paris']);
}
}
private static function migrateIvrsTable(PDO $db): void
{
$cols = $db->query("PRAGMA table_info(ivrs)")->fetchAll(PDO::FETCH_ASSOC);
$existing = array_column($cols, 'name');
$extraColumns = [
'description' => "TEXT NOT NULL DEFAULT ''",
'direct_dial' => "INTEGER NOT NULL DEFAULT 1",
'invalid_retries' => "INTEGER NOT NULL DEFAULT 3",
'invalid_sound_id' => "INTEGER NOT NULL DEFAULT 0",
'invalid_dest' => "VARCHAR(64) NOT NULL DEFAULT 'hangup'",
'timeout_retries' => "INTEGER NOT NULL DEFAULT 2",
'timeout_sound_id' => "INTEGER NOT NULL DEFAULT 0",
'timeout_dest' => "VARCHAR(64) NOT NULL DEFAULT 'hangup'",
];
foreach ($extraColumns as $colName => $colDef) {
if (!in_array($colName, $existing, true)) {
$db->exec("ALTER TABLE ivrs ADD COLUMN {$colName} {$colDef}");
}
}
}
private static function migrateCertificatesTable(PDO $db): void
{
$count = (int)$db->query('SELECT COUNT(*) FROM certificates')->fetchColumn();
if ($count === 0) {
$defaultCert = '/etc/asterisk/keys/asterisk.crt';
$defaultKey = '/etc/asterisk/keys/asterisk.key';
$domain = 'pbx.local';
$issuer = 'Asterisk PBX System';
$validFrom = date('Y-m-d H:i:s');
$validTo = date('Y-m-d H:i:s', time() + (3650 * 86400));
$daysRemaining = 3650;
if (file_exists($defaultCert)) {
$raw = @file_get_contents($defaultCert);
if ($raw) {
$parsed = @openssl_x509_parse($raw);
if ($parsed) {
$domain = $parsed['subject']['CN'] ?? 'pbx.local';
$issuer = $parsed['issuer']['O'] ?? ($parsed['issuer']['CN'] ?? 'Asterisk Self-Signed CA');
if (!empty($parsed['validFrom_time_t'])) {
$validFrom = date('Y-m-d H:i:s', $parsed['validFrom_time_t']);
}
if (!empty($parsed['validTo_time_t'])) {
$validTo = date('Y-m-d H:i:s', $parsed['validTo_time_t']);
$daysRemaining = (int)round(($parsed['validTo_time_t'] - time()) / 86400);
}
}
}
}
$stmt = $db->prepare('INSERT INTO certificates (
name, type, primary_domain, alt_names, issuer, valid_from, valid_to, days_remaining,
cert_path, key_path, chain_path, fullchain_path, is_default, status
) VALUES (
:name, :type, :domain, :alt_names, :issuer, :valid_from, :valid_to, :days_rem,
:cert_path, :key_path, :chain_path, :fullchain_path, 1, :status
)');
$stmt->execute([
':name' => 'Certificat Auto-signé Système',
':type' => 'selfsigned',
':domain' => $domain,
':alt_names' => '',
':issuer' => $issuer,
':valid_from' => $validFrom,
':valid_to' => $validTo,
':days_rem' => $daysRemaining,
':cert_path' => $defaultCert,
':key_path' => $defaultKey,
':chain_path' => '',
':fullchain_path' => $defaultCert,
':status' => ($daysRemaining > 0) ? 'valid' : 'expired',
]);
}
}
private static function migrateFeatureCodesTable(PDO $db): void
{
$db->exec("CREATE TABLE IF NOT EXISTS feature_codes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
feature_key VARCHAR(64) UNIQUE NOT NULL,
code VARCHAR(16) NOT NULL,
default_code VARCHAR(16) NOT NULL,
name VARCHAR(128) NOT NULL,
category VARCHAR(64) NOT NULL,
description TEXT NOT NULL DEFAULT '',
enabled INTEGER NOT NULL DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)");
$defaults = [
// Supervision & Interception BLF
['pickup_blf', '**', '**', "Interception d'appel dirigée (BLF Yealink)", 'pickup', "Récupère l'appel du poste surveillé en appuyant sur sa touche BLF ou en composant ** suivi de son numéro."],
['pickup_group', '*8', '*8', "Interception d'appel de groupe", 'pickup', "Récupère n'importe quel appel qui sonne dans votre groupe de postes (ou composez *8 suivi du numéro pour une capture dirigée)."],
// Renvois d'appels
['cf_activate', '*72', '*72', "Activer le renvoi d'appels inconditionnel", 'forward', "Renvoie immédiatement tous les appels vers un autre numéro (composez *72 ou *72 suivi du numéro de destination)."],
['cf_deactivate', '*73', '*73', "Désactiver le renvoi d'appels inconditionnel", 'forward', "Annule le renvoi inconditionnel de vos appels."],
['cfu_activate', '*52', '*52', "Activer le renvoi sur non-réponse / indisponibilité", 'forward', "Renvoie vos appels uniquement en cas de non-réponse ou d'indisponibilité."],
['cfu_deactivate', '*53', '*53', "Désactiver le renvoi sur non-réponse / indisponibilité", 'forward', "Annule le renvoi sur non-réponse / indisponibilité."],
// Ne pas déranger (DND)
['dnd_activate', '*78', '*78', "Activer le mode Ne-pas-Déranger (DND)", 'dnd', "Rejette les appels entrants en envoyant le signal occupé ou renvoie vers la messagerie."],
['dnd_deactivate', '*79', '*79', "Désactiver le mode Ne-pas-Déranger (DND)", 'dnd', "Rétablit la sonnerie normale de votre combiné téléphonique."],
['dnd_toggle', '*76', '*76', "Inverser l'état Ne-pas-Déranger (Toggle)", 'dnd', "Bascule rapidement entre l'état Ne-pas-déranger Actif et Inactif."],
// Messagerie vocale
['voicemail_direct', '*97', '*97', "Accès direct à votre boîte vocale", 'voicemail', "Consulte vos messages vocaux sans mot de passe depuis votre propre poste."],
['voicemail_general', '*98', '*98', "Consulter la boîte vocale d'une autre extension", 'voicemail', "Accède à n'importe quelle boîte vocale en saisissant le numéro de poste et le code PIN."],
// Sécurité & Filtrage
['blacklist_add', '*30', '*30', "Ajouter un numéro à la liste noire (Blacklist)", 'security', "Bloque définitivement les appels provenant d'un numéro indésirable."],
['blacklist_remove', '*31', '*31', "Retirer un numéro de la liste noire", 'security', "Débloque un numéro précédemment blacklisté."],
['blacklist_last', '*32', '*32', "Bloquer instantanément le dernier appelant", 'security', "Ajoute immédiatement le numéro du dernier appel reçu à la liste noire."],
// Outils & Diagnostics
['speak_exten', '*65', '*65', "Identifier vocalement votre numéro de poste", 'tools', "Asterisk énonce à voix haute le numéro de l'extension actuelle (Speak your extension)."],
['last_caller', '*69', '*69', "Identifier vocalement le dernier appelant reçu", 'tools', "Énonce le numéro du dernier appel reçu et propose de le rappeler."],
['echo_test', '*43', '*43', "Test d'écho audio bidirectionnel", 'tools', "Permet de tester micro, écouteur et latence réseau en renvoyant votre voix en miroir."],
['speaking_clock', '*60', '*60', "Horloge parlante / Test audio", 'tools', "Donne la date et l'heure exacte et valide le fonctionnement des codecs audio."],
['moh_test', '*44', '*44', "Test d'écoute de la musique d'attente (MoH)", 'tools', "Diffuse la musique d'attente générale pour valider la qualité sonore."]
];
$stmtCheck = $db->prepare('SELECT COUNT(*) FROM feature_codes WHERE feature_key = :k');
$stmtInsert = $db->prepare('INSERT INTO feature_codes (feature_key, code, default_code, name, category, description, enabled) VALUES (:k, :c, :def, :n, :cat, :d, 1)');
foreach ($defaults as $item) {
$stmtCheck->execute([':k' => $item[0]]);
if ((int)$stmtCheck->fetchColumn() === 0) {
$stmtInsert->execute([
':k' => $item[0],
':c' => $item[1],
':def' => $item[2],
':n' => $item[3],
':cat' => $item[4],
':d' => $item[5],
]);
}
}
}
}
+553
View File
@@ -0,0 +1,553 @@
<?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';
/**
* 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
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',
];
}
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'),
];
}
/**
* 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
exec('sudo /usr/bin/fail2ban-client ping 2>&1', $pingOut, $pingCode);
$isRunning = ($pingCode === 0 && str_contains(implode(' ', $pingOut), 'Server replied: pong'));
$jails = [];
$bannedList = [];
$totalBannedCount = 0;
if ($isRunning) {
exec('sudo /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
$currentIp = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1';
if (str_contains($currentIp, ',')) {
$currentIp = trim(explode(',', $currentIp)[0]);
}
$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 = "sudo /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', 'sshd'] as $j) {
$cmd = "sudo /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 = "sudo /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();
$strictPort80 = !empty($data['certbot_strict_port80']) ? 1 : 0;
$firewallEnabled = (!empty($data['firewall_enabled']) || $strictPort80 === 1) ? 1 : 0;
$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)));
$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,
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,
]);
// 2. Génération et application du fichier /etc/fail2ban/jail.local
self::generateJailLocal(
$finalWhitelist,
$sipMaxRetry,
$sipFindTime,
$sipBanTime,
$webMaxRetry,
$webBanTime,
$adminPort
);
// 3. Rechargement de Fail2ban
exec('sudo /usr/bin/fail2ban-client reload 2>&1', $f2bOut, $f2bCode);
// 4. Mise à jour de la configuration Nginx
self::applyNginxConfig($adminPort, $strictPort80);
@unlink('/tmp/nexus_fw_status_cache.json');
return [
'success' => true,
'message' => 'Configuration Pare-feu & 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
): void {
$portsWeb = "http,https,{$adminPort}";
$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}
banaction = nftables-multiport
banaction_allports = nftables-allports
[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/security.log
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
EOF;
file_put_contents(self::JAIL_LOCAL_PATH, $conf);
}
/**
* Met à jour Nginx pour séparer le port 80 (Certbot/ACME uniquement si strict) et écouter sur le port d'admin.
*/
public static function applyNginxConfig(int $adminHttpsPort, int $strictPort80): void
{
$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 64M;
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 64M;
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;
$fullNginxConfig = $port80Block . "\n\n" . $phonebookBlock . "\n\n" . $httpsBlock . "\n";
file_put_contents(self::NGINX_CONF_PATH, $fullNginxConfig);
// Test de syntaxe
exec('sudo /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 /usr/bin/systemctl reload nginx 2>&1');
}
private static function getJailDetails(string $jail): array
{
exec('sudo /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 'FailedLogin' /var/log/nexus_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',
'sshd' => 'Accès Console SSH',
default => ucfirst($jail),
};
}
}
+303
View File
@@ -0,0 +1,303 @@
<?php
declare(strict_types=1);
namespace App;
use PDO;
use Exception;
use RuntimeException;
use InvalidArgumentException;
class PhonebookManager
{
public const AVATARS_DIR = '/var/www/nexus-pbx/public/avatars';
/**
* Initialise le dossier des avatars.
*/
public static function initDirectory(): void
{
if (!is_dir(self::AVATARS_DIR)) {
@mkdir(self::AVATARS_DIR, 0775, true);
}
@chown(self::AVATARS_DIR, 'www-data');
@chgrp(self::AVATARS_DIR, 'www-data');
}
/**
* Synchronise automatiquement les postes d'extensions vers l'annuaire d'entreprise.
*/
public static function syncExtensions(PDO $db): void
{
// 1. Récupère la cartographie des groupes d'extensions
$egRows = $db->query("SELECT id, name, members FROM extension_groups")->fetchAll();
$extToGroup = [];
foreach ($egRows as $eg) {
$members = json_decode((string)$eg['members'], true) ?: [];
foreach ($members as $extNum) {
if (!isset($extToGroup[(string)$extNum])) {
$extToGroup[(string)$extNum] = trim((string)$eg['name']);
}
}
}
// 2. Récupère tous les postes
$exts = $db->query("SELECT id, extension, name, voicemail_email FROM extensions")->fetchAll();
$activeExtIds = [];
$findStmt = $db->prepare("SELECT id, display_name, internal_number, email, group_name FROM phonebook_contacts WHERE extension_id = :ext_id OR (extension_id IS NULL AND is_internal = 1 AND internal_number = :num)");
$insertStmt = $db->prepare("
INSERT INTO phonebook_contacts (extension_id, display_name, internal_number, email, group_name, is_internal)
VALUES (:ext_id, :name, :num, :email, :grp, 1)
");
$updateStmt = $db->prepare("
UPDATE phonebook_contacts
SET extension_id = :ext_id, display_name = :name, internal_number = :num, email = :email, group_name = :grp, updated_at = CURRENT_TIMESTAMP
WHERE id = :id
");
foreach ($exts as $ext) {
$extId = (int)$ext['id'];
$extNum = trim((string)$ext['extension']);
$extName = trim((string)$ext['name']);
$email = trim((string)($ext['voicemail_email'] ?? ''));
$group = $extToGroup[$extNum] ?? 'Général';
$activeExtIds[] = $extId;
$findStmt->execute([':ext_id' => $extId, ':num' => $extNum]);
$existing = $findStmt->fetch(PDO::FETCH_ASSOC);
if ($existing) {
$updateStmt->execute([
':id' => (int)$existing['id'],
':ext_id' => $extId,
':name' => $extName,
':num' => $extNum,
':email' => $email,
':grp' => $group,
]);
} else {
$insertStmt->execute([
':ext_id' => $extId,
':name' => $extName,
':num' => $extNum,
':email' => $email,
':grp' => $group,
]);
}
}
// 3. Supprime les contacts internes orphelins (si extension supprimée)
if (!empty($activeExtIds)) {
$inClause = implode(',', $activeExtIds);
$db->exec("DELETE FROM phonebook_contacts WHERE is_internal = 1 AND extension_id IS NOT NULL AND extension_id NOT IN ({$inClause})");
}
}
/**
* Récupère la liste des contacts filtrée ou non par groupe.
*/
public static function getContacts(PDO $db, ?string $group = null): array
{
self::syncExtensions($db);
$params = [];
$sql = "SELECT * FROM phonebook_contacts";
if ($group !== null && trim($group) !== '' && strtolower(trim($group)) !== 'all' && strtolower(trim($group)) !== 'tous') {
$cleanGroup = trim($group);
$sql .= " WHERE LOWER(group_name) = LOWER(:grp) OR LOWER(group_name) LIKE LOWER(:grp_like) OR LOWER(group_name) IN ('tous', 'all')";
$params[':grp'] = $cleanGroup;
$params[':grp_like'] = "%{$cleanGroup}%";
}
$sql .= " ORDER BY display_name COLLATE NOCASE ASC";
$stmt = $db->prepare($sql);
$stmt->execute($params);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
/**
* Récupère la liste de tous les groupes d'annuaire disponibles.
*/
public static function getGroups(PDO $db): array
{
$groups = [];
$egRows = $db->query("SELECT name FROM extension_groups ORDER BY name ASC")->fetchAll(PDO::FETCH_COLUMN);
foreach ($egRows as $g) {
$g = trim((string)$g);
if ($g !== '') $groups[$g] = true;
}
$pbRows = $db->query("SELECT DISTINCT group_name FROM phonebook_contacts WHERE group_name != ''")->fetchAll(PDO::FETCH_COLUMN);
foreach ($pbRows as $g) {
$g = trim((string)$g);
if ($g !== '') $groups[$g] = true;
}
// Ajoute Issy-Les-Moulineaux si pas encore présent
if (!isset($groups['Issy-Les-Moulineaux'])) {
$groups['Issy-Les-Moulineaux'] = true;
}
$result = array_keys($groups);
sort($result, SORT_NATURAL | SORT_FLAG_CASE);
return $result;
}
/**
* Enregistre un contact (création ou mise à jour).
*/
public static function saveContact(PDO $db, array $data): int
{
$id = isset($data['id']) && $data['id'] !== '' ? (int)$data['id'] : null;
$name = trim((string)($data['display_name'] ?? ''));
$internal = trim((string)($data['internal_number'] ?? ''));
$mobile = trim((string)($data['mobile_number'] ?? ''));
$other = trim((string)($data['other_number'] ?? ''));
$email = trim((string)($data['email'] ?? ''));
$photo = trim((string)($data['photo_url'] ?? ''));
$group = trim((string)($data['group_name'] ?? 'Général')) ?: 'Général';
$isInternal = !empty($data['is_internal']) ? 1 : 0;
$extensionId = !empty($data['extension_id']) ? (int)$data['extension_id'] : null;
if ($name === '') {
throw new InvalidArgumentException("Le nom du contact est obligatoire.");
}
if ($internal === '' && $mobile === '' && $other === '') {
throw new InvalidArgumentException("Veuillez renseigner au moins un numéro de téléphone (Poste, Mobile ou Autre).");
}
if ($id) {
$stmt = $db->prepare("
UPDATE phonebook_contacts
SET display_name = :name, internal_number = :internal, mobile_number = :mobile,
other_number = :other, email = :email, photo_url = :photo, group_name = :grp,
is_internal = :is_int, extension_id = :ext_id, updated_at = CURRENT_TIMESTAMP
WHERE id = :id
");
$stmt->execute([
':id' => $id,
':name' => $name,
':internal' => $internal,
':mobile' => $mobile,
':other' => $other,
':email' => $email,
':photo' => $photo,
':grp' => $group,
':is_int' => $isInternal,
':ext_id' => $extensionId,
]);
return $id;
} else {
$stmt = $db->prepare("
INSERT INTO phonebook_contacts (display_name, internal_number, mobile_number, other_number, email, photo_url, group_name, is_internal, extension_id)
VALUES (:name, :internal, :mobile, :other, :email, :photo, :grp, :is_int, :ext_id)
");
$stmt->execute([
':name' => $name,
':internal' => $internal,
':mobile' => $mobile,
':other' => $other,
':email' => $email,
':photo' => $photo,
':grp' => $group,
':is_int' => $isInternal,
':ext_id' => $extensionId,
]);
return (int)$db->lastInsertId();
}
}
/**
* Supprime un contact d'annuaire.
*/
public static function deleteContact(PDO $db, int $id): bool
{
$stmt = $db->prepare("DELETE FROM phonebook_contacts WHERE id = :id");
return $stmt->execute([':id' => $id]);
}
/**
* Détermine l'URL de base pour l'accès aux photos et avatars depuis les postes IP.
*/
public static function getBaseUrl(?PDO $db = null): string
{
if (!empty($_SERVER['HTTP_HOST'])) {
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
return "{$scheme}://" . $_SERVER['HTTP_HOST'];
}
if ($db) {
try {
$st = $db->query("SELECT domain, external_ip FROM settings WHERE id = 1")->fetch(PDO::FETCH_ASSOC);
if (!empty($st['external_ip'])) {
return "http://" . $st['external_ip'] . ":8080";
}
if (!empty($st['domain']) && $st['domain'] !== 'pbx.local') {
return "http://" . $st['domain'] . ":8080";
}
} catch (\Throwable $e) {}
}
$serverIp = !empty($_SERVER['SERVER_ADDR']) ? $_SERVER['SERVER_ADDR'] : '127.0.0.1';
return "http://{$serverIp}:8080";
}
/**
* Génère le flux XML officiel Yealink IP Phone Remote Address Book.
*/
public static function renderYealinkXml(PDO $db, ?string $group = null): string
{
$contacts = self::getContacts($db, $group);
$baseUrl = self::getBaseUrl($db);
$groupTitle = ($group !== null && trim($group) !== '' && strtolower(trim($group)) !== 'all')
? trim($group)
: 'NexusPBX';
$xml = new \SimpleXMLElement('<?xml version="1.0" encoding="UTF-8"?><YealinkIPPhoneDirectory/>');
$xml->addChild('Title', "Annuaire " . htmlspecialchars($groupTitle, ENT_XML1, 'UTF-8'));
foreach ($contacts as $c) {
$name = trim((string)$c['display_name']);
$phone1 = trim((string)$c['internal_number']);
$phone2 = trim((string)$c['mobile_number']);
$phone3 = trim((string)$c['other_number']);
$email = trim((string)($c['email'] ?? ''));
$photo = trim((string)($c['photo_url'] ?? ''));
if ($phone1 === '' && $phone2 !== '') {
$phone1 = $phone2;
$phone2 = '';
}
if ($phone1 === '' && $phone2 === '' && $phone3 === '') {
continue;
}
$entry = $xml->addChild('DirectoryEntry');
$entry->addChild('Name', htmlspecialchars($name, ENT_XML1, 'UTF-8'));
$entry->addChild('Telephone', htmlspecialchars($phone1, ENT_XML1, 'UTF-8'));
if ($phone2 !== '') {
$entry->addChild('Mobile', htmlspecialchars($phone2, ENT_XML1, 'UTF-8'));
}
if ($phone3 !== '') {
$entry->addChild('OtherTelephone', htmlspecialchars($phone3, ENT_XML1, 'UTF-8'));
}
if ($email !== '') {
$entry->addChild('Email', htmlspecialchars($email, ENT_XML1, 'UTF-8'));
}
if ($photo !== '') {
$fullPhotoUrl = (strpos($photo, '://') === false)
? rtrim($baseUrl, '/') . '/' . ltrim($photo, '/')
: $photo;
$entry->addChild('Photo', htmlspecialchars($fullPhotoUrl, ENT_XML1, 'UTF-8'));
$entry->addChild('Picture', htmlspecialchars($fullPhotoUrl, ENT_XML1, 'UTF-8'));
}
}
$dom = dom_import_simplexml($xml)->ownerDocument;
$dom->formatOutput = true;
return $dom->saveXML();
}
}
+209
View File
@@ -0,0 +1,209 @@
<?php
declare(strict_types=1);
namespace App;
use PDO;
use Throwable;
class SystemChecker
{
public static function runDiagnostics(PDO $db): array
{
$settings = $db->query('SELECT * FROM settings WHERE id = 1')->fetch() ?: [];
$leDomain = trim((string)($settings['letsencrypt_domain'] ?? ''));
$checks = [];
// 1. OS Debian & PHP SQLite3
$osPretty = 'Linux Debian';
if (is_readable('/etc/os-release')) {
$osContent = (string)file_get_contents('/etc/os-release');
if (preg_match('/PRETTY_NAME="([^"]+)"/', $osContent, $m)) {
$osPretty = $m[1];
}
}
$checks[] = [
'id' => 'debian_os',
'label' => 'Système Debian & PHP ' . PHP_VERSION,
'ok' => extension_loaded('pdo_sqlite'),
'detail' => $osPretty . ' (PDO SQLite actif)',
];
// 2. Base SQLite accessible en écriture
$dbWritable = is_writable(Database::DB_PATH) && is_writable(dirname(Database::DB_PATH));
$checks[] = [
'id' => 'sqlite_db',
'label' => 'Base de données SQLite3 (Source unique)',
'ok' => $dbWritable,
'detail' => $dbWritable
? 'Lecture/Écriture OK (' . realpath(Database::DB_PATH) . ')'
: 'Permissions insuffisantes sur storage/database.sqlite',
];
// 3. Utilisateur et Groupe Debian asterisk:asterisk
$astUser = function_exists('posix_getpwnam') ? posix_getpwnam('asterisk') : true;
$astGroup = function_exists('posix_getgrnam') ? posix_getgrnam('asterisk') : true;
$checks[] = [
'id' => 'asterisk_user',
'label' => 'Utilisateur & Groupe Debian (asterisk:asterisk)',
'ok' => (bool)($astUser && $astGroup),
'detail' => ($astUser && $astGroup)
? 'Utilisateur système asterisk:asterisk présent'
: 'Utilisateur ou groupe asterisk introuvable sur Debian',
];
// 4. Droits d'écriture sur /etc/asterisk et *.custom.conf
$etcWritable = is_dir('/etc/asterisk') && is_writable('/etc/asterisk');
$pjsipOk = !file_exists(ConfigGenerator::PJSIP_CONF_PATH) ? $etcWritable : is_writable(ConfigGenerator::PJSIP_CONF_PATH);
$extOk = !file_exists(ConfigGenerator::EXTENSIONS_CONF_PATH) ? $etcWritable : is_writable(ConfigGenerator::EXTENSIONS_CONF_PATH);
$permOk = $etcWritable && $pjsipOk && $extOk;
$checks[] = [
'id' => 'asterisk_perms',
'label' => 'Droits d\'écriture sur /etc/asterisk/*.custom.conf',
'ok' => $permOk,
'detail' => $permOk
? '/etc/asterisk accessible en écriture par le worker PHP (groupe asterisk)'
: 'Accès refusé en écriture dans /etc/asterisk pour PHP-FPM',
];
// 5. Directives #tryinclude dans pjsip.conf et extensions.conf
$pjsipMain = @file_get_contents('/etc/asterisk/pjsip.conf') ?: '';
$extMain = @file_get_contents('/etc/asterisk/extensions.conf') ?: '';
$incOk = str_contains($pjsipMain, 'pjsip.custom.conf') && str_contains($extMain, 'extensions.custom.conf');
$checks[] = [
'id' => 'asterisk_includes',
'label' => 'Inclusions natives (#tryinclude *.custom.conf)',
'ok' => $incOk,
'detail' => $incOk
? 'pjsip.conf et extensions.conf incluent bien les fichiers .custom.conf'
: 'Directive #tryinclude manquante dans pjsip.conf ou extensions.conf',
];
// 6. Certificats TLS (Let's Encrypt ou Certificat local Debian)
if ($leDomain !== '') {
$leCert = "/etc/letsencrypt/live/{$leDomain}/fullchain.pem";
$leKey = "/etc/letsencrypt/live/{$leDomain}/privkey.pem";
$leOk = is_readable($leCert) && is_readable($leKey);
$checks[] = [
'id' => 'tls_certs',
'label' => "Certificat TLS Let's Encrypt ({$leDomain})",
'ok' => $leOk,
'detail' => $leOk
? "Certificat Let's Encrypt lisible ({$leCert})"
: "Certificat introuvable ou non lisible dans /etc/letsencrypt/live/{$leDomain}/",
];
} else {
$fallbackOk = is_readable('/etc/asterisk/keys/asterisk.crt') && is_readable('/etc/asterisk/keys/asterisk.key');
$checks[] = [
'id' => 'tls_certs',
'label' => 'Certificats TLS SIP (Port 5061)',
'ok' => $fallbackOk,
'detail' => $fallbackOk
? 'Certificat TLS local actif (/etc/asterisk/keys/asterisk.crt) — prêt pour Let\'s Encrypt'
: 'Aucun domaine Let\'s Encrypt configuré',
];
}
// 7. Connexion Asterisk Manager Interface (AMI)
$amiOk = false;
$amiDetail = '';
try {
$ami = AmiClient::fromDatabase($db);
$info = $ami->testConnection();
$amiOk = true;
$amiDetail = ($info['version'] ?: $info['banner']) . ' (Socket AMI 127.0.0.1:' . ($settings['ami_port'] ?? 5038) . ')';
} catch (Throwable $e) {
$amiDetail = $e->getMessage();
}
$checks[] = [
'id' => 'ami_connection',
'label' => 'Liaison Asterisk Manager Interface (AMI)',
'ok' => $amiOk,
'detail' => $amiDetail,
];
$allCriticalOk = true;
foreach ($checks as $c) {
if (!$c['ok'] && $c['id'] !== 'tls_certs') {
$allCriticalOk = false;
}
}
return [
'healthy' => $allCriticalOk,
'checks' => $checks,
];
}
public static function ensureAsteriskIncludes(): void
{
$pjsipConf = '/etc/asterisk/pjsip.conf';
if (is_file($pjsipConf) && is_writable($pjsipConf)) {
$content = (string)file_get_contents($pjsipConf);
if (!str_contains($content, 'pjsip.custom.conf')) {
file_put_contents($pjsipConf, rtrim($content) . "\n\n#tryinclude \"pjsip.custom.conf\"\n");
}
}
$extConf = '/etc/asterisk/extensions.conf';
if (is_file($extConf) && is_writable($extConf)) {
$content = (string)file_get_contents($extConf);
if (!str_contains($content, 'extensions.custom.conf')) {
file_put_contents($extConf, rtrim($content) . "\n\n#tryinclude \"extensions.custom.conf\"\n");
}
}
$mohConf = '/etc/asterisk/musiconhold.conf';
if (is_file($mohConf) && is_writable($mohConf)) {
$content = (string)file_get_contents($mohConf);
// Désactive la classe [default] native d'Asterisk pour laisser la priorité à musiconhold.custom.conf
$cleaned = preg_replace('/^\[default\]/m', ';[default]', $content);
$cleaned = preg_replace('/^mode=files/m', ';mode=files', $cleaned);
$cleaned = preg_replace('/^directory=moh/m', ';directory=moh', $cleaned);
if (!str_contains($cleaned, 'musiconhold.custom.conf')) {
$cleaned = rtrim($cleaned) . "\n\n#tryinclude \"/etc/asterisk/musiconhold.custom.conf\"\n";
}
if ($cleaned !== $content) {
file_put_contents($mohConf, $cleaned);
}
}
$vmConf = '/etc/asterisk/voicemail.conf';
if (is_file($vmConf) && is_writable($vmConf)) {
$content = (string)file_get_contents($vmConf);
$cleaned = preg_replace('/^1234 => 4242.*/m', ';1234 => 4242,Example Mailbox,root@localhost', $content);
$cleaned = preg_replace('/^;\s*passwordlocation\s*=\s*spooldir/m', 'passwordlocation=spooldir', $cleaned);
$cleaned = preg_replace('/^;\s*externpassnotify\s*=.*/m', 'externpassnotify=/usr/bin/php /var/www/nexus-pbx/scripts/voicemail_pass_sync.php', $cleaned);
if (!str_contains($cleaned, 'voicemail.custom.conf')) {
$cleaned = rtrim($cleaned) . "\n\n#tryinclude \"voicemail.custom.conf\"\n";
}
if ($cleaned !== $content) {
file_put_contents($vmConf, $cleaned);
}
}
$astConf = '/etc/asterisk/asterisk.conf';
if (is_file($astConf) && is_writable($astConf)) {
$content = (string)file_get_contents($astConf);
$lang = 'fr';
try {
$db = Database::getConnection();
$s = $db->query('SELECT system_language FROM settings WHERE id = 1')->fetch();
if (!empty($s['system_language'])) {
$lang = trim((string)$s['system_language']);
}
} catch (\Throwable $e) {}
if (preg_match('/^;?\s*defaultlanguage\s*=/m', $content)) {
$cleaned = preg_replace('/^;?\s*defaultlanguage\s*=.*$/m', "defaultlanguage = {$lang}", $content);
} else {
$cleaned = preg_replace('/\[options\]/i', "[options]\ndefaultlanguage = {$lang}", $content);
}
if ($cleaned !== $content) {
file_put_contents($astConf, $cleaned);
}
}
}
}
+403
View File
@@ -0,0 +1,403 @@
<?php
declare(strict_types=1);
namespace App;
use PDO;
use Throwable;
require_once __DIR__ . '/FirewallManager.php';
require_once __DIR__ . '/AmiClient.php';
class SystemMetrics
{
private const CACHE_FILE = __DIR__ . '/../storage/metrics_prev.json';
public static function collect(PDO $db): array
{
$now = microtime(true);
$prev = self::loadPrevSample();
// 1. CPU (/proc/stat + /proc/loadavg + /proc/cpuinfo)
$cpuStat = self::readCpuStat();
$cpuPercent = 0.0;
if (isset($prev['cpu'])) {
$dTotal = $cpuStat['total'] - $prev['cpu']['total'];
$dIdle = $cpuStat['idle'] - $prev['cpu']['idle'];
if ($dTotal > 0) {
$cpuPercent = round(max(0.0, min(100.0, (1.0 - ($dIdle / $dTotal)) * 100.0)), 1);
}
}
$loadAvg = sys_getloadavg() ?: [0.0, 0.0, 0.0];
$cpuCores = 1;
$cpuModel = 'Processeur Debian';
if (is_readable('/proc/cpuinfo')) {
$cpuinfo = (string)file_get_contents('/proc/cpuinfo');
$cpuCores = max(1, substr_count($cpuinfo, 'processor'));
if (preg_match('/model name\s*:\s*(.+)/i', $cpuinfo, $m)) {
$cpuModel = trim($m[1]);
}
}
// 2. Mémoire RAM & Swap (/proc/meminfo)
$mem = self::readMemInfo();
// 3. Disque HDD/SSD (/)
$diskTotal = (float)(@disk_total_space('/') ?: 1);
$diskFree = (float)(@disk_free_space('/') ?: 0);
$diskUsed = max(0.0, $diskTotal - $diskFree);
$diskPct = round(($diskUsed / $diskTotal) * 100.0, 1);
// 4. Réseau (/proc/net/dev)
$net = self::readNetDev();
$rxRateKbps = 0.0;
$txRateKbps = 0.0;
if (isset($prev['net'], $prev['timestamp']) && ($now - $prev['timestamp']) > 0.2) {
$dt = $now - $prev['timestamp'];
$dRx = max(0, $net['rx_bytes'] - $prev['net']['rx_bytes']);
$dTx = max(0, $net['tx_bytes'] - $prev['net']['tx_bytes']);
// En Ko/s (KB/s)
$rxRateKbps = round(($dRx / $dt) / 1024, 2);
$txRateKbps = round(($dTx / $dt) / 1024, 2);
}
// Sauvegarde de l'échantillon pour le calcul différentiel CPU & Réseau
self::savePrevSample([
'timestamp' => $now,
'cpu' => $cpuStat,
'net' => $net,
]);
// 5. Uptime Debian (/proc/uptime)
$uptimeSeconds = 0;
if (is_readable('/proc/uptime')) {
$parts = explode(' ', trim((string)file_get_contents('/proc/uptime')));
$uptimeSeconds = (int)floor((float)($parts[0] ?? 0));
}
// 6. Version Debian & État temps réel Asterisk via AMI (Postes enregistrés, latence RTT, appels actifs)
$debianVer = is_readable('/etc/debian_version') ? trim((string)file_get_contents('/etc/debian_version')) : '13';
$asteriskInfo = self::readAsteriskLiveStatus($db);
// 7. Statut des Services Debian & Fail2ban / Pare-feu
$fwStatus = FirewallManager::getStatus();
$services = self::readServicesStatus($asteriskInfo, $fwStatus);
return [
'timestamp' => date('H:i:s'),
'hostname' => gethostname() ?: 'debian-pbx',
'os_version' => "Debian {$debianVer}",
'uptime' => self::formatDuration($uptimeSeconds),
'cpu' => [
'percent' => $cpuPercent,
'cores' => $cpuCores,
'model' => $cpuModel,
'load_1' => round((float)$loadAvg[0], 2),
'load_5' => round((float)$loadAvg[1], 2),
'load_15' => round((float)$loadAvg[2], 2),
],
'memory' => $mem,
'disk' => [
'total_gb' => round($diskTotal / 1073741824, 2),
'used_gb' => round($diskUsed / 1073741824, 2),
'free_gb' => round($diskFree / 1073741824, 2),
'percent' => $diskPct,
],
'network' => [
'interface' => $net['interface'],
'ip' => $net['ip'],
'rx_kbps' => $rxRateKbps,
'tx_kbps' => $txRateKbps,
'rx_total_mb' => round($net['rx_bytes'] / 1048576, 1),
'tx_total_mb' => round($net['tx_bytes'] / 1048576, 1),
],
'asterisk' => $asteriskInfo,
'services' => $services,
'security' => [
'firewall_enabled' => (bool)($fwStatus['settings']['firewall_enabled'] ?? false),
'fail2ban_running' => (bool)($fwStatus['is_running'] ?? false),
'admin_port' => (int)($fwStatus['settings']['admin_https_port'] ?? 8443),
'strict_port80' => (bool)($fwStatus['settings']['certbot_strict_port80'] ?? false),
'total_banned' => (int)($fwStatus['total_banned'] ?? 0),
'banned_ips' => $fwStatus['banned_ips'] ?? [],
'jails' => $fwStatus['jails'] ?? [],
],
];
}
private static function readServicesStatus(array $astInfo, array $fwStatus): array
{
$out = trim((string)@shell_exec('systemctl is-active asterisk nginx php8.4-fpm fail2ban 2>/dev/null'));
$lines = preg_split('/\r?\n/', $out) ?: [];
$astActive = ($lines[0] ?? '') === 'active' || ($astInfo['uptime'] ?? '') !== 'Hors-ligne';
$nginxActive = ($lines[1] ?? '') === 'active';
$phpActive = ($lines[2] ?? '') === 'active';
$f2bActive = ($lines[3] ?? '') === 'active' || !empty($fwStatus['is_running']);
$isF2bRunning = !empty($fwStatus['is_running']) && (($lines[3] ?? '') === 'active');
$fwEnabled = !empty($fwStatus['settings']['firewall_enabled']) && $isF2bRunning;
$adminPort = $fwStatus['settings']['admin_https_port'] ?? 8443;
return [
[
'id' => 'asterisk',
'name' => 'Asterisk 22 PBX',
'desc' => 'Moteur SIP PJSIP & Dialplan',
'port' => '5060 / 5061',
'active' => $astActive,
'status_text' => $astActive ? 'Opérationnel' : 'Arrêté',
'uptime' => $astInfo['uptime'] ?? 'Actif',
],
[
'id' => 'nginx',
'name' => 'Serveur Web Nginx',
'desc' => 'Portail GUI & Reverse Proxy',
'port' => "80 / 443 / {$adminPort}",
'active' => $nginxActive,
'status_text' => $nginxActive ? 'Opérationnel' : 'Arrêté',
'uptime' => 'En ligne',
],
[
'id' => 'php',
'name' => 'PHP 8.4 FPM',
'desc' => 'API FastCGI & SQLite',
'port' => 'Unix Socket',
'active' => $phpActive,
'status_text' => $phpActive ? 'Opérationnel' : 'Arrêté',
'uptime' => 'En ligne',
],
[
'id' => 'fail2ban',
'name' => 'Fail2ban IDS / IPS',
'desc' => 'Jails [asterisk, web, ssh]',
'port' => 'Surveillance logs',
'active' => $f2bActive,
'status_text' => $f2bActive ? 'En surveillance' : 'Arrêté',
'uptime' => $f2bActive ? 'Actif' : 'Arrêté',
],
[
'id' => 'firewall',
'name' => 'Pare-feu & Filtrage',
'desc' => $fwEnabled ? 'Règles actives & Isolation 80' : 'Inactif (Tout autoriser)',
'port' => 'Filtrage IP & Nginx',
'active' => $fwEnabled,
'status_text' => $fwEnabled ? 'Filtrage Actif' : 'Inactif',
'uptime' => $fwEnabled ? 'Actif' : 'Inactif',
],
];
}
private static function readCpuStat(): array
{
if (!is_readable('/proc/stat')) {
return ['total' => 0, 'idle' => 0];
}
$firstLine = fgets(fopen('/proc/stat', 'r')) ?: '';
$parts = preg_split('/\s+/', trim($firstLine)) ?: [];
array_shift($parts); // retire "cpu"
$vals = array_map('intval', $parts);
$idle = ($vals[3] ?? 0) + ($vals[4] ?? 0); // idle + iowait
$total = array_sum($vals);
return ['total' => $total, 'idle' => $idle];
}
private static function readMemInfo(): array
{
$data = [];
if (is_readable('/proc/meminfo')) {
foreach (file('/proc/meminfo') ?: [] as $line) {
if (preg_match('/^(\w+):\s+(\d+)/', $line, $m)) {
$data[$m[1]] = (int)$m[2]; // en kB
}
}
}
$totalKb = max(1, $data['MemTotal'] ?? 1);
$availKb = $data['MemAvailable'] ?? (($data['MemFree'] ?? 0) + ($data['Buffers'] ?? 0) + ($data['Cached'] ?? 0));
$usedKb = max(0, $totalKb - $availKb);
$swapTotalKb = $data['SwapTotal'] ?? 0;
$swapFreeKb = $data['SwapFree'] ?? 0;
$swapUsedKb = max(0, $swapTotalKb - $swapFreeKb);
return [
'total_mb' => (int)round($totalKb / 1024),
'used_mb' => (int)round($usedKb / 1024),
'free_mb' => (int)round($availKb / 1024),
'percent' => round(($usedKb / $totalKb) * 100.0, 1),
'swap_total_mb' => (int)round($swapTotalKb / 1024),
'swap_used_mb' => (int)round($swapUsedKb / 1024),
];
}
private static function readNetDev(): array
{
$iface = 'eth0';
$rxBytes = 0;
$txBytes = 0;
if (is_readable('/proc/net/dev')) {
$lines = file('/proc/net/dev') ?: [];
foreach ($lines as $line) {
if (!str_contains($line, ':')) {
continue;
}
[$name, $rest] = explode(':', $line, 2);
$name = trim($name);
if ($name === 'lo') {
continue;
}
$cols = preg_split('/\s+/', trim($rest)) ?: [];
$iface = $name;
$rxBytes = (int)($cols[0] ?? 0);
$txBytes = (int)($cols[8] ?? 0);
break;
}
}
$ip = $_SERVER['SERVER_ADDR'] ?? '';
if ($ip === '' || $ip === '127.0.0.1') {
$out = @shell_exec("ip -4 -o addr show dev " . escapeshellarg($iface) . " 2>/dev/null");
if (is_string($out) && preg_match('/inet\s+([0-9.]+)/', $out, $m)) {
$ip = $m[1];
}
}
return [
'interface' => $iface,
'ip' => $ip ?: '127.0.0.1',
'rx_bytes' => $rxBytes,
'tx_bytes' => $txBytes,
];
}
private static function readAsteriskLiveStatus(PDO $db): array
{
$contacts = [];
$activeCalls = 0;
$astUptime = 'Actif';
$astVersion = 'Asterisk 22 • PJSIP';
$onlineCount = 0;
try {
$ami = AmiClient::fromDatabase($db);
$rawVer = $ami->runCommand('core show version');
if (preg_match('/Asterisk\s+([0-9]+\.[0-9]+(?:\.[0-9]+)?)/i', $rawVer, $vm)) {
$astVersion = "Asterisk {$vm[1]} • PJSIP";
}
$rawContacts = $ami->runCommand('pjsip show contacts');
$astDbRaw = $ami->runCommand('database show registrar/contact');
$contactMeta = [];
foreach (preg_split('/\r?\n/', $astDbRaw) ?: [] as $dbLine) {
if (preg_match('/^\/registrar\/contact\/([^;]+);@[a-f0-9]+:\s*(\{.*\})/i', $dbLine, $dbM)) {
$extKey = $dbM[1];
$parsedJson = json_decode($dbM[2], true);
if ($parsedJson) {
$contactMeta[$extKey] = $parsedJson;
}
}
}
foreach (preg_split('/\r?\n/', $rawContacts) ?: [] as $line) {
// Contact: 101/sip:101@10.10.167.162:5060 5dd9a21be8 Avail 9.234
// or with -nan: Contact: 102/sip:102@10.10.168.228:5060;x-ast-txp=trans bb0686ce80 Unavail -nan
if (preg_match('/Contact:\s+([^\s\/]+)\/sip:([^\s]+)\s+([a-f0-9]+)\s+(\S+)\s+(-?[0-9.]+|nan|-nan)/i', $line, $m)) {
$extNum = $m[1];
$uri = $m[2];
$status = $m[4];
$isAvail = (stripos($status, 'Avail') !== false && stripos($status, 'Unavail') === false) || stripos($status, 'NonQual') !== false;
if ($isAvail) {
$onlineCount++;
}
$meta = $contactMeta[$extNum] ?? [];
$fullUri = $meta['uri'] ?? ("sip:" . $uri);
$userAgent = $meta['user_agent'] ?? '';
// Détection précise du transport utilisé en direct
$activeTransport = 'udp';
if (preg_match('/transport=(tls|wss|ws|tcp|udp)/i', $fullUri, $tm)) {
$activeTransport = strtolower($tm[1]);
} elseif (preg_match('/x-ast-txp=transport-(tls|wss|ws|tcp|udp)/i', $fullUri, $tm)) {
$activeTransport = strtolower($tm[1]);
} elseif (str_starts_with($fullUri, 'sips:')) {
$activeTransport = 'tls';
}
$contacts[] = [
'extension' => $extNum,
'uri' => $fullUri,
'user_agent' => $userAgent,
'active_transport' => $activeTransport,
'status' => $status,
'rtt_ms' => is_numeric($m[5]) ? round((float)$m[5], 1) : null,
];
}
}
$rawChannels = $ami->runCommand('core show channels count');
if (preg_match('/(\d+)\s+active\s+call/i', $rawChannels, $cm)) {
$activeCalls = (int)$cm[1];
}
$rawUptime = $ami->runCommand('core show uptime');
if (preg_match('/System uptime:\s*(.+)/i', $rawUptime, $um)) {
$astUptime = trim($um[1]);
}
$registrations = [];
$rawRegs = $ami->runCommand('pjsip show registrations');
foreach (preg_split('/\r?\n/', $rawRegs) ?: [] as $line) {
// trunk-ovh-reg/sip:siptrunk.ovh.net:5060 trunk-ovh-auth Registered
if (preg_match('/^\s*(trunk-[^\s\/]+)-reg\/(\S+)\s+(\S+)\s+(\S+)/i', $line, $rm)) {
$registrations[$rm[1]] = [
'server_uri' => $rm[2],
'auth' => $rm[3],
'status' => $rm[4],
];
}
}
} catch (Throwable) {
$astUptime = 'Hors-ligne';
$registrations = [];
}
return [
'version' => $astVersion,
'online_contacts' => $onlineCount,
'active_calls' => $activeCalls,
'uptime' => $astUptime,
'contacts' => $contacts,
'registrations' => $registrations ?? [],
];
}
private static function loadPrevSample(): array
{
if (!is_readable(self::CACHE_FILE)) {
return [];
}
$raw = @file_get_contents(self::CACHE_FILE);
return $raw ? (json_decode($raw, true) ?: []) : [];
}
private static function savePrevSample(array $sample): void
{
@file_put_contents(self::CACHE_FILE, json_encode($sample));
}
private static function formatDuration(int $seconds): string
{
$days = intdiv($seconds, 86400);
$hours = intdiv($seconds % 86400, 3600);
$mins = intdiv($seconds % 3600, 60);
if ($days > 0) {
return "{$days}j {$hours}h {$mins}m";
}
if ($hours > 0) {
return "{$hours}h {$mins}m";
}
return "{$mins} min";
}
}