Initial commit - NexusPBX IP Telephony Platform

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

No files matched your search

+522
View File
@@ -0,0 +1,522 @@
<?php
declare(strict_types=1);
namespace App;
use PDO;
use InvalidArgumentException;
use RuntimeException;
class CertificateManager
{
public const KEYS_BASE_DIR = '/etc/asterisk/keys';
public const WEBROOT_DIR = '/var/www/nexus-pbx/public';
/**
* Récupère la liste de tous les certificats avec recalcul dynamique de l'expiration.
*/
public static function getAll(PDO $db): array
{
$certs = $db->query('SELECT * FROM certificates ORDER BY is_default DESC, id DESC')->fetchAll();
$now = time();
foreach ($certs as &$c) {
$validTo = !empty($c['valid_to']) ? strtotime($c['valid_to']) : null;
if ($validTo) {
$days = (int)round(($validTo - $now) / 86400);
$c['days_remaining'] = $days;
if ($days < 0) {
$c['status'] = 'expired';
} elseif ($days <= 15) {
$c['status'] = 'expiring_soon';
} else {
$c['status'] = 'valid';
}
}
}
unset($c);
return $certs;
}
/**
* Récupère un certificat par son ID.
*/
public static function getById(PDO $db, int $id): ?array
{
$stmt = $db->prepare('SELECT * FROM certificates WHERE id = :id');
$stmt->execute([':id' => $id]);
$cert = $stmt->fetch();
return $cert ?: null;
}
/**
* Récupère le certificat actif par défaut pour Asterisk PJSIP TLS et WebRTC.
*/
public static function getDefault(PDO $db): ?array
{
$stmt = $db->query('SELECT * FROM certificates WHERE is_default = 1 LIMIT 1');
$cert = $stmt->fetch();
if (!$cert) {
$stmt = $db->query('SELECT * FROM certificates ORDER BY id ASC LIMIT 1');
$cert = $stmt->fetch();
}
return $cert ?: null;
}
/**
* Analyse et extrait les métadonnées d'un certificat au format PEM.
*/
public static function parseCertPem(string $certPem): array
{
$certPem = trim($certPem);
if ($certPem === '' || !str_contains($certPem, '-----BEGIN CERTIFICATE-----')) {
throw new InvalidArgumentException("Format de certificat invalide : aucun bloc '-----BEGIN CERTIFICATE-----' trouvé.");
}
$parsed = @openssl_x509_parse($certPem, true);
if ($parsed === false) {
$parsed = @openssl_x509_parse($certPem, false);
}
if ($parsed === false) {
throw new InvalidArgumentException("Impossible d'analyser le certificat X.509 fourni (fichier corrompu ou format non supporté).");
}
$domain = $parsed['subject']['CN'] ?? ($parsed['subject']['commonName'] ?? '');
$altNames = [];
if (!empty($parsed['extensions']['subjectAltName'])) {
$sanRaw = $parsed['extensions']['subjectAltName'];
$parts = explode(',', $sanRaw);
foreach ($parts as $p) {
$p = trim($p);
if (str_starts_with($p, 'DNS:')) {
$altNames[] = substr($p, 4);
} elseif (str_starts_with($p, 'IP Address:')) {
$altNames[] = substr($p, 11);
}
}
}
if ($domain === '' && !empty($altNames)) {
$domain = $altNames[0];
}
$issuer = $parsed['issuer']['O'] ?? ($parsed['issuer']['organizationName'] ?? ($parsed['issuer']['CN'] ?? ($parsed['issuer']['commonName'] ?? 'Inconnu')));
$validFrom = !empty($parsed['validFrom_time_t']) ? date('Y-m-d H:i:s', $parsed['validFrom_time_t']) : null;
$validTo = !empty($parsed['validTo_time_t']) ? date('Y-m-d H:i:s', $parsed['validTo_time_t']) : null;
$daysRemaining = !empty($parsed['validTo_time_t']) ? (int)round(($parsed['validTo_time_t'] - time()) / 86400) : 0;
return [
'domain' => $domain ?: 'domaine-inconnu',
'alt_names' => implode(', ', $altNames),
'issuer' => $issuer,
'valid_from' => $validFrom,
'valid_to' => $validTo,
'days_remaining' => $daysRemaining,
'status' => ($daysRemaining > 15) ? 'valid' : (($daysRemaining >= 0) ? 'expiring_soon' : 'expired'),
];
}
/**
* Valide qu'une clé privée correspond bien au certificat public.
*/
public static function validateKeyPair(string $certPem, string $keyPem, ?string $passphrase = null): bool
{
$certPem = trim($certPem);
$keyPem = trim($keyPem);
if ($keyPem === '' || (!str_contains($keyPem, 'KEY-----') && !str_contains($keyPem, 'BEGIN RSA PRIVATE KEY'))) {
throw new InvalidArgumentException("Format de clé privée invalide : aucun bloc clé privée PEM détecté.");
}
$privKeyResource = openssl_pkey_get_private($keyPem, $passphrase ?? '');
if ($privKeyResource === false) {
throw new InvalidArgumentException("La clé privée est invalide ou chiffrée avec une phrase secrète non renseignée.");
}
$match = openssl_x509_check_private_key($certPem, $privKeyResource);
if (!$match) {
throw new InvalidArgumentException("Incohérence de paire de clés : la clé privée fournie NE CORRESPOND PAS au certificat public.");
}
return true;
}
/**
* Enregistre un certificat importé manuellement (DigiCert, Sectigo, OVH, etc.).
*/
public static function saveCustomCert(
PDO $db,
string $name,
string $certPem,
string $keyPem,
string $chainPem = '',
bool $makeDefault = false
): array {
$meta = self::parseCertPem($certPem);
self::validateKeyPair($certPem, $keyPem);
$name = trim($name);
if ($name === '') {
$name = 'Certificat ' . $meta['domain'];
}
$chainPem = trim($chainPem);
$fullchainPem = ($chainPem !== '') ? ($certPem . "\n" . $chainPem) : $certPem;
// Insertion en base pour obtenir l'ID
$stmt = $db->prepare('INSERT INTO certificates (
name, type, primary_domain, alt_names, issuer, valid_from, valid_to, days_remaining,
cert_path, key_path, chain_path, fullchain_path, email, auto_renew, is_default, status
) VALUES (
:name, :type, :domain, :alt_names, :issuer, :valid_from, :valid_to, :days_rem,
"", "", "", "", :email, 0, 0, :status
)');
$stmt->execute([
':name' => $name,
':type' => 'custom',
':domain' => $meta['domain'],
':alt_names' => $meta['alt_names'],
':issuer' => $meta['issuer'],
':valid_from' => $meta['valid_from'],
':valid_to' => $meta['valid_to'],
':days_rem' => $meta['days_remaining'],
':email' => '',
':status' => $meta['status'],
]);
$certId = (int)$db->lastInsertId();
$targetDir = self::KEYS_BASE_DIR . '/cert_' . $certId;
if (!is_dir($targetDir)) {
mkdir($targetDir, 0770, true);
}
$certPath = $targetDir . '/cert.pem';
$keyPath = $targetDir . '/privkey.pem';
$chainPath = ($chainPem !== '') ? $targetDir . '/chain.pem' : '';
$fullchainPath = $targetDir . '/fullchain.pem';
file_put_contents($certPath, $certPem . "\n");
file_put_contents($keyPath, $keyPem . "\n");
if ($chainPem !== '') {
file_put_contents($chainPath, $chainPem . "\n");
}
file_put_contents($fullchainPath, $fullchainPem . "\n");
self::secureKeyPermissions($targetDir);
$upd = $db->prepare('UPDATE certificates SET
cert_path = :cp,
key_path = :kp,
chain_path = :chp,
fullchain_path = :fcp
WHERE id = :id');
$upd->execute([
':cp' => $certPath,
':kp' => $keyPath,
':chp' => $chainPath,
':fcp' => $fullchainPath,
':id' => $certId,
]);
if ($makeDefault) {
self::setDefault($db, $certId);
}
return self::getById($db, $certId);
}
/**
* Génère un certificat SSL automatique via Let's Encrypt (Certbot HTTP-01 Webroot).
*/
public static function requestLetsEncrypt(
PDO $db,
string $name,
string $domain,
string $email,
bool $makeDefault = true,
bool $staging = false
): array {
$domain = strtolower(trim($domain));
$email = trim($email);
if (!preg_match('/^([a-z0-9]([a-z0-9\-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/i', $domain)) {
throw new InvalidArgumentException("Le nom de domaine '{$domain}' est invalide.");
}
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new InvalidArgumentException("L'adresse email '{$email}' est invalide.");
}
$name = trim($name);
if ($name === '') {
$name = "Let's Encrypt - " . $domain;
}
$challengeDir = self::WEBROOT_DIR . '/.well-known/acme-challenge';
if (!is_dir($challengeDir)) {
mkdir($challengeDir, 0775, true);
}
$escDomain = escapeshellarg($domain);
$escEmail = escapeshellarg($email);
$escWebroot = escapeshellarg(self::WEBROOT_DIR);
$stagingFlag = $staging ? '--test-cert' : '';
// Exécution de Certbot webroot non-interactive
$cmd = "certbot certonly --webroot -w {$escWebroot} -d {$escDomain} --non-interactive --agree-tos -m {$escEmail} --keep-until-expiring {$stagingFlag} 2>&1";
$output = [];
$exitCode = 0;
exec($cmd, $output, $exitCode);
$outputStr = implode("\n", $output);
if ($exitCode !== 0) {
throw new RuntimeException("Échec de la génération Let's Encrypt (Code: {$exitCode}) :\n" . $outputStr);
}
$liveDir = "/etc/letsencrypt/live/{$domain}";
$liveCert = $liveDir . '/cert.pem';
$liveKey = $liveDir . '/privkey.pem';
$liveChain = $liveDir . '/chain.pem';
$liveFullchain = $liveDir . '/fullchain.pem';
if (!file_exists($liveFullchain) || !file_exists($liveKey)) {
throw new RuntimeException("Certbot a terminé mais les fichiers de certificat n'ont pas été trouvés dans {$liveDir}.");
}
$certPem = file_get_contents($liveCert);
$meta = self::parseCertPem($certPem);
// Insertion en base
$stmt = $db->prepare('INSERT INTO certificates (
name, type, primary_domain, alt_names, issuer, valid_from, valid_to, days_remaining,
cert_path, key_path, chain_path, fullchain_path, email, auto_renew, is_default, status
) VALUES (
:name, :type, :domain, :alt_names, :issuer, :valid_from, :valid_to, :days_rem,
"", "", "", "", :email, 1, 0, :status
)');
$stmt->execute([
':name' => $name,
':type' => 'letsencrypt',
':domain' => $domain,
':alt_names' => $meta['alt_names'],
':issuer' => "Let's Encrypt",
':valid_from' => $meta['valid_from'],
':valid_to' => $meta['valid_to'],
':days_rem' => $meta['days_remaining'],
':email' => $email,
':status' => $meta['status'],
]);
$certId = (int)$db->lastInsertId();
$targetDir = self::KEYS_BASE_DIR . '/cert_' . $certId;
if (!is_dir($targetDir)) {
mkdir($targetDir, 0770, true);
}
$certPath = $targetDir . '/cert.pem';
$keyPath = $targetDir . '/privkey.pem';
$chainPath = $targetDir . '/chain.pem';
$fullchainPath = $targetDir . '/fullchain.pem';
copy($liveCert, $certPath);
copy($liveKey, $keyPath);
copy($liveChain, $chainPath);
copy($liveFullchain, $fullchainPath);
self::secureKeyPermissions($targetDir);
$upd = $db->prepare('UPDATE certificates SET
cert_path = :cp,
key_path = :kp,
chain_path = :chp,
fullchain_path = :fcp
WHERE id = :id');
$upd->execute([
':cp' => $certPath,
':kp' => $keyPath,
':chp' => $chainPath,
':fcp' => $fullchainPath,
':id' => $certId,
]);
if ($makeDefault) {
self::setDefault($db, $certId);
}
return self::getById($db, $certId);
}
/**
* Définit le certificat actif par défaut.
*/
public static function setDefault(PDO $db, int $id): bool
{
$cert = self::getById($db, $id);
if (!$cert) {
throw new InvalidArgumentException("Certificat introuvable (ID: {$id}).");
}
$db->beginTransaction();
try {
$db->exec('UPDATE certificates SET is_default = 0');
$stmt = $db->prepare('UPDATE certificates SET is_default = 1 WHERE id = :id');
$stmt->execute([':id' => $id]);
$db->commit();
} catch (\Throwable $e) {
$db->rollBack();
throw $e;
}
// Synchronisation des fichiers actifs pour Nginx et Asterisk
$activeCertFile = self::KEYS_BASE_DIR . '/active_cert.crt';
$activeKeyFile = self::KEYS_BASE_DIR . '/active_key.key';
$sourceCert = !empty($cert['fullchain_path']) && file_exists($cert['fullchain_path'])
? $cert['fullchain_path']
: $cert['cert_path'];
$sourceKey = $cert['key_path'];
if (file_exists($sourceCert) && file_exists($sourceKey)) {
@copy($sourceCert, $activeCertFile);
@copy($sourceKey, $activeKeyFile);
@chmod($activeCertFile, 0640);
@chmod($activeKeyFile, 0640);
@chgrp($activeCertFile, 'asterisk');
@chgrp($activeKeyFile, 'asterisk');
}
return true;
}
/**
* Supprime un certificat.
*/
public static function delete(PDO $db, int $id): bool
{
$cert = self::getById($db, $id);
if (!$cert) {
throw new InvalidArgumentException("Certificat introuvable (ID: {$id}).");
}
$totalCerts = (int)$db->query('SELECT COUNT(*) FROM certificates')->fetchColumn();
if ($totalCerts <= 1) {
throw new RuntimeException("Impossible de supprimer le dernier certificat de la plateforme.");
}
if ((int)$cert['is_default'] === 1) {
throw new RuntimeException("Ce certificat est actuellement actif par défaut. Veuillez d'abord activer un autre certificat.");
}
$targetDir = self::KEYS_BASE_DIR . '/cert_' . $id;
if (is_dir($targetDir)) {
$files = glob($targetDir . '/*');
foreach ($files as $f) {
if (is_file($f)) {
@unlink($f);
}
}
@rmdir($targetDir);
}
$stmt = $db->prepare('DELETE FROM certificates WHERE id = :id');
return $stmt->execute([':id' => $id]);
}
/**
* Renouvelle un certificat Let's Encrypt.
*/
public static function renew(PDO $db, int $id): array
{
$cert = self::getById($db, $id);
if (!$cert) {
throw new InvalidArgumentException("Certificat introuvable (ID: {$id}).");
}
if ($cert['type'] !== 'letsencrypt') {
throw new InvalidArgumentException("Seuls les certificats gérés par Let's Encrypt peuvent être renouvelés automatiquement. Pour les certificats personnalisés, importez la nouvelle version.");
}
$domain = $cert['primary_domain'];
$escDomain = escapeshellarg($domain);
$cmd = "certbot renew --cert-name {$escDomain} --force-renewal 2>&1";
$output = [];
$exitCode = 0;
exec($cmd, $output, $exitCode);
$outputStr = implode("\n", $output);
if ($exitCode !== 0) {
throw new RuntimeException("Échec du renouvellement Certbot (Code: {$exitCode}) :\n" . $outputStr);
}
$liveDir = "/etc/letsencrypt/live/{$domain}";
$liveCert = $liveDir . '/cert.pem';
$liveKey = $liveDir . '/privkey.pem';
$liveChain = $liveDir . '/chain.pem';
$liveFullchain = $liveDir . '/fullchain.pem';
if (file_exists($liveCert)) {
$certPem = file_get_contents($liveCert);
$meta = self::parseCertPem($certPem);
$targetDir = self::KEYS_BASE_DIR . '/cert_' . $id;
if (!is_dir($targetDir)) {
mkdir($targetDir, 0770, true);
}
copy($liveCert, $targetDir . '/cert.pem');
copy($liveKey, $targetDir . '/privkey.pem');
copy($liveChain, $targetDir . '/chain.pem');
copy($liveFullchain, $targetDir . '/fullchain.pem');
self::secureKeyPermissions($targetDir);
$upd = $db->prepare('UPDATE certificates SET
valid_from = :vf,
valid_to = :vt,
days_remaining = :dr,
status = :st,
updated_at = CURRENT_TIMESTAMP
WHERE id = :id');
$upd->execute([
':vf' => $meta['valid_from'],
':vt' => $meta['valid_to'],
':dr' => $meta['days_remaining'],
':st' => $meta['status'],
':id' => $id,
]);
if ((int)$cert['is_default'] === 1) {
self::setDefault($db, $id);
}
}
return self::getById($db, $id);
}
/**
* Applique les permissions sécurisées pour Asterisk.
*/
private static function secureKeyPermissions(string $dir): void
{
if (!is_dir($dir)) {
return;
}
@chmod($dir, 0770);
@chgrp($dir, 'asterisk');
$files = glob($dir . '/*');
foreach ($files as $f) {
if (is_file($f)) {
@chmod($f, 0640);
@chgrp($f, 'asterisk');
}
}
}
}