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,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');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user