Initial commit - NexusPBX IP Telephony Platform
This commit is contained in:
commit
c5de8d0c71
25 files changed
+18123
No files matched your search
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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],
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user