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 (Clé RSA 2048 universellement compatible téléphones IP Yealink/Fanvil/Grandstream) $cmd = "certbot certonly --webroot -w {$escWebroot} -d {$escDomain} --non-interactive --agree-tos -m {$escEmail} --keep-until-expiring --key-type rsa --rsa-key-size 2048 {$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'); } } } }