receiveSyncPayload($rawBody ?: '', $signature); echo json_encode($res); exit; } Auth::startSession(); // Pour les requêtes en lecture seule fréquente (polling métriques / statut), libérer immédiatement le verrou de session // Cela évite de saturer les workers PHP-FPM par des verrous de fichiers sessions concurrents if (session_status() === PHP_SESSION_ACTIVE && ($action === 'get_metrics' || $action === 'cluster_status' || $action === 'get_system_info')) { session_write_close(); } // Actions publiques (Login / Logout) if ($action === 'login') { $username = trim((string)($payload['username'] ?? '')); $password = (string)($payload['password'] ?? ''); if (Auth::login($db, $username, $password)) { echo json_encode([ 'success' => true, 'username' => Auth::getUsername(), 'message' => 'Connexion réussie.', ]); } else { // Journalisation de sécurité pour Fail2ban (jail nexus-web) $clientIp = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1'; if (str_contains($clientIp, ',')) { $clientIp = trim(explode(',', $clientIp)[0]); } $logMsg = date('[Y-m-d H:i:s] ') . "[NEXUS-WEB-AUTH-FAIL]: " . $clientIp . " (username: " . ($username ?: 'vide') . ")\n"; @file_put_contents('/var/log/nexus_security.log', $logMsg, FILE_APPEND | LOCK_EX); http_response_code(401); echo json_encode([ 'success' => false, 'message' => 'Identifiants incorrects.', ]); } exit; } if ($action === 'logout') { Auth::logout(); echo json_encode(['success' => true, 'message' => 'Session fermée.']); exit; } // Toutes les autres actions requièrent une session authentifiée if (!Auth::isAuthenticated()) { http_response_code(401); echo json_encode([ 'success' => false, 'unauthenticated' => true, 'message' => 'Session expirée ou non authentifiée.', ]); exit; } $generator = new ConfigGenerator($db); switch ($action) { case 'get_state': $settings = $db->query('SELECT * FROM settings WHERE id = 1')->fetch(); $extensions = $db->query('SELECT * FROM extensions ORDER BY CAST(extension AS INTEGER) ASC, extension ASC')->fetchAll(); $groups = $db->query('SELECT * FROM groups ORDER BY CAST(extension AS INTEGER) ASC, extension ASC')->fetchAll(); $pagingGroups = $db->query('SELECT * FROM paging_groups ORDER BY CAST(extension AS INTEGER) ASC, extension ASC')->fetchAll(); $extensionGroups = $db->query('SELECT * FROM extension_groups ORDER BY id ASC')->fetchAll(); $trunks = $db->query('SELECT * FROM trunks ORDER BY id ASC')->fetchAll(); $ivrs = $db->query('SELECT * FROM ivrs ORDER BY id ASC')->fetchAll(); $outboundRoutes = $db->query('SELECT * FROM outbound_routes ORDER BY priority ASC, id ASC')->fetchAll(); $sounds = $db->query('SELECT * FROM sounds ORDER BY site_category ASC, usage_type ASC, id ASC')->fetchAll(); $soundSites = $db->query('SELECT * FROM sound_sites ORDER BY id ASC')->fetchAll(); foreach ($groups as &$g) { $g['members'] = json_decode((string)$g['members'], true) ?: []; } unset($g); foreach ($pagingGroups as &$pg) { $pg['members'] = json_decode((string)$pg['members'], true) ?: []; } unset($pg); foreach ($extensionGroups as &$eg) { $eg['members'] = json_decode((string)$eg['members'], true) ?: []; } unset($eg); foreach ($trunks as &$t) { $t['did_routes'] = ConfigGenerator::normalizeInboundRoutes((string)($t['did_routes_json'] ?? '[]')); } unset($t); foreach ($ivrs as &$i) { $i['options'] = json_decode((string)$i['options_json'], true) ?: []; } unset($i); foreach ($outboundRoutes as &$or) { $or['trunk_sequence'] = json_decode((string)($or['trunk_sequence_json'] ?? '[]'), true) ?: []; $or['dial_patterns'] = json_decode((string)($or['dial_patterns_json'] ?? '[]'), true) ?: []; $or['allowed_groups'] = json_decode((string)($or['allowed_groups_json'] ?? '[]'), true) ?: []; $or['allowed_extensions'] = json_decode((string)($or['allowed_extensions_json'] ?? '[]'), true) ?: []; } unset($or); $settings['local_networks'] = json_decode((string)($settings['local_networks_json'] ?? '[]'), true) ?: []; if (empty($settings['local_networks']) && !empty($settings['local_net'])) { $settings['local_networks'] = [$settings['local_net']]; } $appDir = dirname(__DIR__); $installedVerData = file_exists("{$appDir}/version.json") ? json_decode((string)file_get_contents("{$appDir}/version.json"), true) : []; $installedVer = $installedVerData['version'] ?? '1.5.0'; echo json_encode([ 'success' => true, 'version' => $installedVer, 'user' => Auth::getUsername(), 'settings' => $settings, 'extensions' => $extensions, 'groups' => $groups, 'paging_groups' => $pagingGroups, 'extension_groups' => $extensionGroups, 'trunks' => $trunks, 'ivrs' => $ivrs, 'outbound_routes' => $outboundRoutes, 'sounds' => $sounds, 'sound_sites' => $soundSites, 'feature_codes' => $db->query('SELECT * FROM feature_codes ORDER BY category, id')->fetchAll(), 'certificates' => CertificateManager::getAll($db), 'active_certificate' => CertificateManager::getDefault($db), 'diagnostics' => SystemChecker::runDiagnostics($db), 'backups_count' => count(BackupManager::listBackups($db)), 'phonebook_contacts' => PhonebookManager::getContacts($db), 'phonebook_groups' => PhonebookManager::getGroups($db), 'metrics' => SystemMetrics::collect($db), 'previews' => [ 'pjsip' => $generator->generatePjsipConf(), 'extensions' => $generator->generateExtensionsConf(), 'moh' => $generator->generateMusiconholdConf(), 'rtp' => $generator->generateRtpConf(), 'voicemail' => $generator->generateVoicemailConf(), ], ]); break; case 'get_metrics': echo json_encode([ 'success' => true, 'metrics' => SystemMetrics::collect($db), ]); break; case 'get_security_events': $events = []; $logFile = '/var/log/asterisk/security.log'; if (is_readable($logFile)) { $lines = file($logFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: []; foreach (array_reverse($lines) as $line) { if (!str_contains($line, 'ChallengeResponseFailed') && !str_contains($line, 'InvalidAccountID') && !str_contains($line, 'FailedAuth')) { continue; } preg_match('/\[([^\]]+)\]/', $line, $tm); preg_match('/SecurityEvent="([^"]+)"/', $line, $em); preg_match('/Service="([^"]+)"/', $line, $sm); preg_match('/AccountID="([^"]+)"/', $line, $am); preg_match('/RemoteAddress="IPV[46]\/[A-Z]+\/([^\/]+)/', $line, $rm); $events[] = [ 'time' => $tm[1] ?? date('M d H:i:s'), 'event' => $em[1] ?? 'FailedAuth', 'service' => $sm[1] ?? 'PJSIP', 'account' => $am[1] ?? '-', 'ip' => $rm[1] ?? '-', ]; if (count($events) >= 50) break; } } echo json_encode([ 'success' => true, 'events' => $events, ]); break; case 'clear_security_log': $logFile = '/var/log/asterisk/security.log'; if (file_exists($logFile)) { @file_put_contents($logFile, ''); @chown($logFile, 'asterisk'); @chgrp($logFile, 'asterisk'); @chmod($logFile, 0664); } $webLog = '/var/log/nexus_security.log'; if (file_exists($webLog)) { @file_put_contents($webLog, ''); @chown($webLog, 'www-data'); @chgrp($webLog, 'asterisk'); @chmod($webLog, 0664); } @unlink('/tmp/nexus_fw_status_cache.json'); exec('sudo /usr/bin/systemctl restart fail2ban 2>&1 || sudo /usr/bin/fail2ban-client reload 2>&1'); echo json_encode([ 'success' => true, 'message' => 'Le journal des événements de sécurité a été vidé avec succès.', ]); break; case 'save_extension': $id = isset($payload['id']) && $payload['id'] !== '' ? (int)$payload['id'] : null; $extension = trim((string)($payload['extension'] ?? '')); $name = trim((string)($payload['name'] ?? '')); $secret = trim((string)($payload['secret'] ?? '')); $transport = ($payload['transport'] ?? 'udp') === 'tls' ? 'tls' : 'udp'; $encryption = max(0, min(2, (int)($payload['encryption'] ?? ($transport === 'tls' ? 1 : 0)))); $codecs = preg_replace('/[^a-z0-9,]/', '', strtolower((string)($payload['codecs'] ?? 'alaw,ulaw,opus'))); $max_contacts = max(1, min(10, (int)($payload['max_contacts'] ?? 2))); $enabled = isset($payload['enabled']) ? (int)!empty($payload['enabled']) : 1; $voicemail_enabled = isset($payload['voicemail_enabled']) ? (int)!empty($payload['voicemail_enabled']) : 0; $force_pin_change = isset($payload['force_pin_change']) ? (int)!empty($payload['force_pin_change']) : 0; $voicemail_pin = ($force_pin_change === 1) ? '' : preg_replace('/[^\d]/', '', (string)($payload['voicemail_pin'] ?? '')); $voicemail_skip_pin = isset($payload['voicemail_skip_pin']) ? (int)!empty($payload['voicemail_skip_pin']) : 0; $voicemail_email = trim((string)($payload['voicemail_email'] ?? '')); $voicemail_attach = isset($payload['voicemail_attach']) ? (int)!empty($payload['voicemail_attach']) : 1; $voicemail_delete = isset($payload['voicemail_delete']) ? (int)!empty($payload['voicemail_delete']) : 0; $ring_time = max(5, min(120, (int)($payload['ring_time'] ?? 20))); if ($force_pin_change === 1) { $secFile = "/var/spool/asterisk/voicemail/default/{$extension}/secret.conf"; if (file_exists($secFile)) { @unlink($secFile); } } if (!preg_match('/^\d{2,6}$/', $extension)) { throw new InvalidArgumentException("Le numéro de poste doit comporter entre 2 et 6 chiffres."); } if ($name === '') { throw new InvalidArgumentException("Le nom du poste est obligatoire."); } if (strlen($secret) < 8) { throw new InvalidArgumentException("Le secret SIP doit contenir au moins 8 caractères."); } if ($codecs === '') { $codecs = 'alaw,ulaw'; } if ($id) { $stmt = $db->prepare(' UPDATE extensions SET extension = :ext, name = :name, secret = :secret, transport = :transport, encryption = :encryption, codecs = :codecs, max_contacts = :max_contacts, enabled = :enabled, voicemail_enabled = :vme, voicemail_pin = :vmp, force_pin_change = :fpc, voicemail_skip_pin = :vsp, voicemail_email = :vme_mail, voicemail_attach = :vma, voicemail_delete = :vmd, ring_time = :rt WHERE id = :id '); $stmt->execute([ ':id' => $id, ':ext' => $extension, ':name' => $name, ':secret' => $secret, ':transport' => $transport, ':encryption' => $encryption, ':codecs' => $codecs, ':max_contacts' => $max_contacts, ':enabled' => $enabled, ':vme' => $voicemail_enabled, ':vmp' => $voicemail_pin, ':fpc' => $force_pin_change, ':vsp' => $voicemail_skip_pin, ':vme_mail' => $voicemail_email, ':vma' => $voicemail_attach, ':vmd' => $voicemail_delete, ':rt' => $ring_time, ]); } else { $stmt = $db->prepare(' INSERT INTO extensions (extension, name, secret, transport, encryption, codecs, max_contacts, enabled, voicemail_enabled, voicemail_pin, force_pin_change, voicemail_skip_pin, voicemail_email, voicemail_attach, voicemail_delete, ring_time) VALUES (:ext, :name, :secret, :transport, :encryption, :codecs, :max_contacts, :enabled, :vme, :vmp, :fpc, :vsp, :vme_mail, :vma, :vmd, :rt) ON CONFLICT(extension) DO UPDATE SET name = excluded.name, secret = excluded.secret, transport = excluded.transport, encryption = excluded.encryption, codecs = excluded.codecs, max_contacts = excluded.max_contacts, enabled = excluded.enabled, voicemail_enabled = excluded.voicemail_enabled, voicemail_pin = excluded.voicemail_pin, force_pin_change = excluded.force_pin_change, voicemail_skip_pin = excluded.voicemail_skip_pin, voicemail_email = excluded.voicemail_email, voicemail_attach = excluded.voicemail_attach, voicemail_delete = excluded.voicemail_delete, ring_time = excluded.ring_time '); $stmt->execute([ ':ext' => $extension, ':name' => $name, ':secret' => $secret, ':transport' => $transport, ':encryption' => $encryption, ':codecs' => $codecs, ':max_contacts' => $max_contacts, ':enabled' => $enabled, ':vme' => $voicemail_enabled, ':vmp' => $voicemail_pin, ':fpc' => $force_pin_change, ':vsp' => $voicemail_skip_pin, ':vme_mail' => $voicemail_email, ':vma' => $voicemail_attach, ':vmd' => $voicemail_delete, ':rt' => $ring_time, ]); } // Synchronisation de l'appartenance aux Groupes d'Extensions (Sites / Droits de routes : Nice, Paris...) if (isset($payload['member_extension_groups']) && is_array($payload['member_extension_groups'])) { $selectedExtGroupIds = array_map('intval', $payload['member_extension_groups']); $allExtGroups = $db->query('SELECT id, members FROM extension_groups')->fetchAll(); $updEg = $db->prepare('UPDATE extension_groups SET members = :m WHERE id = :id'); foreach ($allExtGroups as $egRow) { $mArr = json_decode((string)$egRow['members'], true) ?: []; $mArr = array_values(array_filter(array_map('strval', $mArr), fn($x) => $x !== $extension)); if (in_array((int)$egRow['id'], $selectedExtGroupIds, true)) { $mArr[] = $extension; } $updEg->execute([ ':m' => json_encode(array_values(array_unique($mArr))), ':id' => (int)$egRow['id'], ]); } } // Synchronisation automatique de l'annuaire d'entreprise PhonebookManager::syncExtensions($db); // Régénération automatique des fichiers Asterisk et rechargement PJSIP/Dialplan en direct $generator->writeAllConfigs(); try { $ami = AmiClient::fromDatabase($db); $ami->reloadPjsipAndDialplan(); } catch (\Throwable $e) {} $modeBadge = $transport === 'tls' ? 'Distant (TLS + SRTP)' : ($encryption ? 'Interne (UDP + SRTP)' : 'Interne (UDP)'); echo json_encode([ 'success' => true, 'message' => "Poste {$extension} ({$name}) enregistré en mode {$modeBadge}.", ]); break; case 'delete_extension': $id = (int)($payload['id'] ?? 0); $stmt = $db->prepare('DELETE FROM extensions WHERE id = :id'); $stmt->execute([':id' => $id]); PhonebookManager::syncExtensions($db); $generator->writeAllConfigs(); try { $ami = AmiClient::fromDatabase($db); $ami->reloadPjsipAndDialplan(); } catch (\Throwable $e) {} echo json_encode([ 'success' => true, 'message' => 'Poste téléphonique supprimé de la base SQLite.', ]); break; case 'save_group': $id = isset($payload['id']) && $payload['id'] !== '' ? (int)$payload['id'] : null; $extension = trim((string)($payload['extension'] ?? '')); $name = trim((string)($payload['name'] ?? '')); $strategy = in_array($payload['strategy'] ?? '', ['ringall', 'hunt', 'random'], true) ? $payload['strategy'] : 'ringall'; $ring_time = max(5, min(120, (int)($payload['ring_time'] ?? 20))); $members = is_array($payload['members'] ?? null) ? array_values(array_filter(array_map('trim', $payload['members']))) : []; $failover = trim((string)($payload['failover_dest'] ?? 'hangup')) ?: 'hangup'; if (!preg_match('/^\d{2,6}$/', $extension)) { throw new InvalidArgumentException("Le numéro du groupe doit comporter entre 2 et 6 chiffres."); } if ($name === '') { throw new InvalidArgumentException("Le nom du groupe d'appel est obligatoire."); } if ($id) { $stmt = $db->prepare(' UPDATE groups SET extension = :ext, name = :name, strategy = :strategy, ring_time = :ring_time, members = :members, failover_dest = :failover WHERE id = :id '); $stmt->execute([ ':id' => $id, ':ext' => $extension, ':name' => $name, ':strategy' => $strategy, ':ring_time' => $ring_time, ':members' => json_encode($members), ':failover' => $failover, ]); } else { $stmt = $db->prepare(' INSERT INTO groups (extension, name, strategy, ring_time, members, failover_dest) VALUES (:ext, :name, :strategy, :ring_time, :members, :failover) ON CONFLICT(extension) DO UPDATE SET name = excluded.name, strategy = excluded.strategy, ring_time = excluded.ring_time, members = excluded.members, failover_dest = excluded.failover_dest '); $stmt->execute([ ':ext' => $extension, ':name' => $name, ':strategy' => $strategy, ':ring_time' => $ring_time, ':members' => json_encode($members), ':failover' => $failover, ]); } $generator->writeAllConfigs(); try { $ami = AmiClient::fromDatabase($db); $ami->reloadPjsipAndDialplan(); } catch (\Throwable $e) {} echo json_encode([ 'success' => true, 'message' => "Groupe d'appel {$extension} ({$name}) sauvegardé.", ]); break; case 'delete_group': $id = (int)($payload['id'] ?? 0); $db->prepare('DELETE FROM groups WHERE id = :id')->execute([':id' => $id]); $generator->writeAllConfigs(); try { $ami = AmiClient::fromDatabase($db); $ami->reloadPjsipAndDialplan(); } catch (\Throwable $e) {} echo json_encode([ 'success' => true, 'message' => "Groupe de sonnerie supprimé.", ]); break; case 'save_paging_group': $id = isset($payload['id']) && $payload['id'] !== '' ? (int)$payload['id'] : null; $extension = trim((string)($payload['extension'] ?? '')); $name = trim((string)($payload['name'] ?? '')); $desc = trim((string)($payload['description'] ?? '')); $members = is_array($payload['members'] ?? null) ? array_values(array_filter(array_map('trim', $payload['members']))) : []; $duplex = !empty($payload['duplex']) ? 1 : 0; $busyMode = in_array($payload['busy_mode'] ?? '', ['skip', 'force'], true) ? $payload['busy_mode'] : 'skip'; $playBeep = isset($payload['play_beep']) ? (!empty($payload['play_beep']) ? 1 : 0) : 1; $timeout = max(10, min(600, (int)($payload['timeout'] ?? 120))); $enabled = isset($payload['enabled']) ? (!empty($payload['enabled']) ? 1 : 0) : 1; if (!preg_match('/^\d{2,6}$/', $extension)) { throw new InvalidArgumentException("Le numéro du groupe de téléavertissement doit comporter entre 2 et 6 chiffres."); } if ($name === '') { throw new InvalidArgumentException("Le nom du groupe de téléavertissement est obligatoire."); } if (empty($members)) { throw new InvalidArgumentException("Veuillez sélectionner au moins un poste membre pour ce groupe de téléavertissement."); } // Vérification de conflit avec un poste SIP existant $chkExt = $db->prepare('SELECT COUNT(*) FROM extensions WHERE extension = :ext'); $chkExt->execute([':ext' => $extension]); if ((int)$chkExt->fetchColumn() > 0) { throw new InvalidArgumentException("Le numéro {$extension} est déjà assigné à un poste SIP individuel."); } // Vérification de conflit avec un groupe de sonnerie $chkGrp = $db->prepare('SELECT COUNT(*) FROM groups WHERE extension = :ext'); $chkGrp->execute([':ext' => $extension]); if ((int)$chkGrp->fetchColumn() > 0) { throw new InvalidArgumentException("Le numéro {$extension} est déjà assigné à un groupe de sonnerie."); } if ($id) { $stmt = $db->prepare(' UPDATE paging_groups SET extension = :ext, name = :name, description = :desc, members = :members, duplex = :duplex, busy_mode = :busy_mode, play_beep = :play_beep, timeout = :timeout, enabled = :enabled, updated_at = CURRENT_TIMESTAMP WHERE id = :id '); $stmt->execute([ ':id' => $id, ':ext' => $extension, ':name' => $name, ':desc' => $desc, ':members' => json_encode($members), ':duplex' => $duplex, ':busy_mode' => $busyMode, ':play_beep' => $playBeep, ':timeout' => $timeout, ':enabled' => $enabled, ]); } else { $stmt = $db->prepare(' INSERT INTO paging_groups (extension, name, description, members, duplex, busy_mode, play_beep, timeout, enabled) VALUES (:ext, :name, :desc, :members, :duplex, :busy_mode, :play_beep, :timeout, :enabled) ON CONFLICT(extension) DO UPDATE SET name = excluded.name, description = excluded.description, members = excluded.members, duplex = excluded.duplex, busy_mode = excluded.busy_mode, play_beep = excluded.play_beep, timeout = excluded.timeout, enabled = excluded.enabled, updated_at = CURRENT_TIMESTAMP '); $stmt->execute([ ':ext' => $extension, ':name' => $name, ':desc' => $desc, ':members' => json_encode($members), ':duplex' => $duplex, ':busy_mode' => $busyMode, ':play_beep' => $playBeep, ':timeout' => $timeout, ':enabled' => $enabled, ]); } $generator->writeAllConfigs(); try { $ami = AmiClient::fromDatabase($db); $ami->reloadPjsipAndDialplan(); } catch (\Throwable $e) {} echo json_encode([ 'success' => true, 'message' => "Groupe de téléavertissement {$extension} ({$name}) sauvegardé.", ]); break; case 'delete_paging_group': $id = (int)($payload['id'] ?? 0); $db->prepare('DELETE FROM paging_groups WHERE id = :id')->execute([':id' => $id]); $generator->writeAllConfigs(); try { $ami = AmiClient::fromDatabase($db); $ami->reloadPjsipAndDialplan(); } catch (\Throwable $e) {} echo json_encode([ 'success' => true, 'message' => "Groupe de téléavertissement supprimé.", ]); break; case 'save_extension_group': $id = isset($payload['id']) && $payload['id'] !== '' ? (int)$payload['id'] : null; $name = trim((string)($payload['name'] ?? '')); $description = trim((string)($payload['description'] ?? '')); $color = trim((string)($payload['color'] ?? 'cyan')) ?: 'cyan'; $membersRaw = is_array($payload['members'] ?? null) ? $payload['members'] : []; $members = array_values(array_unique(array_filter(array_map('strval', $membersRaw)))); if ($name === '') { throw new InvalidArgumentException("Le nom du groupe d'extensions est obligatoire (ex: Groupe Nice, Groupe Paris)."); } if ($id) { $stmt = $db->prepare(' UPDATE extension_groups SET name = :name, description = :description, color = :color, members = :members WHERE id = :id '); $stmt->execute([ ':id' => $id, ':name' => $name, ':description' => $description, ':color' => $color, ':members' => json_encode($members), ]); } else { $stmt = $db->prepare(' INSERT INTO extension_groups (name, description, color, members) VALUES (:name, :description, :color, :members) ON CONFLICT(name) DO UPDATE SET description = excluded.description, color = excluded.color, members = excluded.members '); $stmt->execute([ ':name' => $name, ':description' => $description, ':color' => $color, ':members' => json_encode($members), ]); } echo json_encode([ 'success' => true, 'message' => "Groupe d'extensions « {$name} » sauvegardé avec " . count($members) . " membre(s).", ]); break; case 'delete_extension_group': $id = (int)($payload['id'] ?? 0); $db->prepare('DELETE FROM extension_groups WHERE id = :id')->execute([':id' => $id]); PhonebookManager::syncExtensions($db); echo json_encode([ 'success' => true, 'message' => "Groupe d'extensions supprimé.", ]); break; case 'save_phonebook_contact': $cid = PhonebookManager::saveContact($db, $payload); echo json_encode([ 'success' => true, 'contact_id' => $cid, 'contacts' => PhonebookManager::getContacts($db), 'groups' => PhonebookManager::getGroups($db), 'message' => "Contact enregistré dans l'annuaire d'entreprise.", ]); break; case 'delete_phonebook_contact': $cid = (int)($payload['id'] ?? 0); PhonebookManager::deleteContact($db, $cid); echo json_encode([ 'success' => true, 'contacts' => PhonebookManager::getContacts($db), 'groups' => PhonebookManager::getGroups($db), 'message' => "Contact supprimé de l'annuaire.", ]); break; case 'sync_phonebook': PhonebookManager::syncExtensions($db); echo json_encode([ 'success' => true, 'contacts' => PhonebookManager::getContacts($db), 'groups' => PhonebookManager::getGroups($db), 'message' => "Annuaire synchronisé avec succès.", ]); break; case 'upload_phonebook_avatar': PhonebookManager::initDirectory(); if (empty($_FILES['avatar']['tmp_name'])) { throw new InvalidArgumentException("Aucun fichier image reçu."); } $file = $_FILES['avatar']; if ($file['error'] !== UPLOAD_ERR_OK) { throw new RuntimeException("Erreur lors de l'envoi du fichier."); } if ($file['size'] > 5 * 1024 * 1024) { throw new InvalidArgumentException("L'image ne doit pas dépasser 5 Mo."); } $finfo = new \finfo(FILEINFO_MIME_TYPE); $mime = $finfo->file($file['tmp_name']); $allowed = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp']; if (!isset($allowed[$mime])) { throw new InvalidArgumentException("Format non supporté. Utilisez JPG, PNG ou WebP."); } $ext = $allowed[$mime]; $cid = isset($_POST['contact_id']) ? preg_replace('/[^\w\-]/', '', (string)$_POST['contact_id']) : 'temp'; $filename = "avatar_{$cid}_" . time() . ".{$ext}"; $target = PhonebookManager::AVATARS_DIR . '/' . $filename; if (!move_uploaded_file($file['tmp_name'], $target)) { throw new RuntimeException("Échec de l'enregistrement de l'avatar."); } @chmod($target, 0664); $avatarUrl = "/avatars/{$filename}"; if (!empty($_POST['contact_id']) && is_numeric($_POST['contact_id'])) { $db->prepare("UPDATE phonebook_contacts SET photo_url = :url, updated_at = CURRENT_TIMESTAMP WHERE id = :id") ->execute([':url' => $avatarUrl, ':id' => (int)$_POST['contact_id']]); } echo json_encode([ 'success' => true, 'photo_url' => $avatarUrl, 'message' => "Photo de profil mise à jour.", ]); break; case 'stream_sound': $id = (int)($_GET['id'] ?? $payload['id'] ?? 0); $stmt = $db->prepare('SELECT * FROM sounds WHERE id = :id'); $stmt->execute([':id' => $id]); $sound = $stmt->fetch(); if (!$sound) { http_response_code(404); echo json_encode(['success' => false, 'message' => 'Fichier son introuvable.']); exit; } $slug = $sound['slug']; $mp3Path = "/var/www/nexus-pbx/storage/sounds/{$slug}.mp3"; $wavPath = "/var/lib/asterisk/sounds/custom/{$slug}.wav"; $filePath = file_exists($mp3Path) ? $mp3Path : (file_exists($wavPath) ? $wavPath : null); if (!$filePath || !is_readable($filePath)) { http_response_code(404); echo json_encode(['success' => false, 'message' => 'Fichier audio non présent sur le disque.']); exit; } $mime = str_ends_with($filePath, '.mp3') ? 'audio/mpeg' : 'audio/wav'; $size = filesize($filePath); while (ob_get_level()) { ob_end_clean(); } header("Content-Type: {$mime}"); header('Accept-Ranges: bytes'); header('Cache-Control: public, max-age=86400'); if (isset($_SERVER['HTTP_RANGE'])) { list($param, $range) = explode('=', $_SERVER['HTTP_RANGE'], 2); if (strtolower(trim($param)) === 'bytes') { list($from, $to) = explode('-', $range, 2); $from = intval($from); $to = ($to === '') ? ($size - 1) : intval($to); if ($from > $to || $from >= $size) { http_response_code(416); header("Content-Range: bytes */{$size}"); exit; } $length = $to - $from + 1; http_response_code(206); header("Content-Range: bytes {$from}-{$to}/{$size}"); header("Content-Length: {$length}"); $fp = fopen($filePath, 'rb'); fseek($fp, $from); $buffer = 1024 * 64; while (!feof($fp) && ($pos = ftell($fp)) <= $to) { if ($pos + $buffer > $to) { $buffer = $to - $pos + 1; } echo fread($fp, $buffer); flush(); } fclose($fp); exit; } } header("Content-Length: {$size}"); readfile($filePath); exit; case 'upload_sound': $name = trim((string)($payload['name'] ?? '')); $siteCategory = trim((string)($payload['site_category'] ?? 'Global')) ?: 'Global'; $usageType = trim((string)($payload['usage_type'] ?? 'announcement')); $description = trim((string)($payload['description'] ?? '')); if ($name === '') { throw new InvalidArgumentException("Le nom du fichier son est obligatoire."); } $allowedUsages = ['moh', 'announcement', 'ivr', 'congestion', 'custom']; if (!in_array($usageType, $allowedUsages, true)) { $usageType = 'announcement'; } if (!isset($_FILES['audio_file']) || $_FILES['audio_file']['error'] !== UPLOAD_ERR_OK) { $errCode = $_FILES['audio_file']['error'] ?? -1; $errMap = [ UPLOAD_ERR_INI_SIZE => "Le fichier audio dépasse la limite maximale autorisée par le serveur (64 Mo).", UPLOAD_ERR_FORM_SIZE => "Le fichier audio dépasse la taille maximale spécifiée.", UPLOAD_ERR_PARTIAL => "Le fichier audio n'a été que partiellement téléversé.", UPLOAD_ERR_NO_FILE => "Veuillez sélectionner un fichier audio à téléverser.", UPLOAD_ERR_NO_TMP_DIR => "Dossier temporaire manquant sur le serveur.", UPLOAD_ERR_CANT_WRITE => "Échec d'écriture du fichier audio sur le disque.", ]; $msg = $errMap[$errCode] ?? "Erreur lors du téléversement du fichier audio (code {$errCode})."; throw new InvalidArgumentException($msg); } $fileInfo = $_FILES['audio_file']; $origFileName = $fileInfo['name']; $tmpPath = $fileInfo['tmp_name']; $fileSize = (int)$fileInfo['size']; if ($fileSize > 35 * 1024 * 1024) { throw new InvalidArgumentException("Le fichier audio dépasse la taille maximale autorisée (35 Mo)."); } // Génération d'un slug unique $slugBase = preg_replace('/[^a-z0-9]+/i', '-', strtolower($name)); $slugBase = trim($slugBase, '-'); if ($slugBase === '') { $slugBase = 'audio-' . time(); } $slug = $slugBase; $counter = 1; while ((int)$db->query("SELECT COUNT(*) FROM sounds WHERE slug = " . $db->quote($slug))->fetchColumn() > 0) { $counter++; $slug = "{$slugBase}-{$counter}"; } // Dossiers cibles $customDir = '/var/lib/asterisk/sounds/custom'; $mohBase = '/var/lib/asterisk/moh'; $storeDir = '/var/www/nexus-pbx/storage/sounds'; @mkdir($customDir, 0775, true); @mkdir($storeDir, 0775, true); $targetWav = "{$customDir}/{$slug}.wav"; $targetAlaw = "{$customDir}/{$slug}.alaw"; $targetMp3 = "{$storeDir}/{$slug}.mp3"; // Fichiers de travail dans storage/sounds (garanti accessible en écriture par www-data) $storeWav = "{$storeDir}/{$slug}.wav"; $storeAlaw = "{$storeDir}/{$slug}.alaw"; // 1. Conversion Asterisk WAV (8kHz mono 16-bit PCM) $cmdWav = sprintf('ffmpeg -y -i %s -ar 8000 -ac 1 -c:a pcm_s16le %s 2>&1', escapeshellarg($tmpPath), escapeshellarg($storeWav)); exec($cmdWav, $outWav, $retWav); if ($retWav !== 0) { @unlink($storeWav); throw new RuntimeException("Erreur de conversion audio WAV Asterisk : " . implode("\n", $outWav)); } // 2. Conversion Asterisk ALAW (G.711a 8kHz mono) $cmdAlaw = sprintf('ffmpeg -y -i %s -ar 8000 -ac 1 -f alaw %s 2>&1', escapeshellarg($tmpPath), escapeshellarg($storeAlaw)); exec($cmdAlaw, $outAlaw, $retAlaw); // 3. Conversion MP3 Web preview $cmdMp3 = sprintf('ffmpeg -y -i %s -c:a libmp3lame -b:a 128k %s 2>&1', escapeshellarg($tmpPath), escapeshellarg($targetMp3)); exec($cmdMp3, $outMp3, $retMp3); // 4. Copie vers les dossiers Asterisk custom @copy($storeWav, $targetWav); if (file_exists($storeAlaw)) { @copy($storeAlaw, $targetAlaw); } // 5. Si musique d'attente (MOH), copier dans le dossier MOH correspondant if ($usageType === 'moh') { $siteSlug = preg_replace('/[^a-z0-9]+/i', '-', strtolower($siteCategory)); $siteSlug = trim($siteSlug, '-') ?: 'default'; if ($siteSlug === 'global') $siteSlug = 'default'; $siteMohDir = "{$mohBase}/{$siteSlug}"; @mkdir($siteMohDir, 0775, true); @copy($storeWav, "{$siteMohDir}/{$slug}.wav"); if (file_exists($storeAlaw)) { @copy($storeAlaw, "{$siteMohDir}/{$slug}.alaw"); } @chown($siteMohDir, 'www-data'); @chgrp($siteMohDir, 'asterisk'); } // Calcul de la durée en secondes $cmdDur = sprintf('ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 %s 2>&1', escapeshellarg($storeWav)); $durStr = trim((string)shell_exec($cmdDur)); $durationSec = max(1, (int)round((float)$durStr)); // Permissions @chown($storeWav, 'www-data'); @chgrp($storeWav, 'asterisk'); @chmod($storeWav, 0664); @chown($targetWav, 'asterisk'); @chgrp($targetWav, 'asterisk'); @chmod($targetWav, 0664); @chown($targetMp3, 'www-data'); @chgrp($targetMp3, 'asterisk'); @chmod($targetMp3, 0664); $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) '); $stmt->execute([ ':name' => $name, ':slug' => $slug, ':site' => $siteCategory, ':usage' => $usageType, ':desc' => $description, ':file' => "{$slug}.wav", ':orig' => $origFileName, ':dur' => $durationSec, ':size' => file_exists($targetWav) ? filesize($targetWav) : $fileSize, ':fmt' => 'wav', ]); // Régénération automatique si MoH if ($usageType === 'moh') { $generator->writeAllConfigs(); try { $ami = AmiClient::fromDatabase($db); $ami->reloadPjsipAndDialplan(); } catch (\Throwable $e) {} } echo json_encode([ 'success' => true, 'message' => "Fichier son « {$name} » téléversé et encodé au format Asterisk avec succès ({$durationSec}s).", ]); break; case 'update_sound': $id = (int)($payload['id'] ?? 0); $name = trim((string)($payload['name'] ?? '')); $siteCategory = trim((string)($payload['site_category'] ?? 'Global')) ?: 'Global'; $usageType = trim((string)($payload['usage_type'] ?? 'announcement')); $description = trim((string)($payload['description'] ?? '')); if (!$id || $name === '') { throw new InvalidArgumentException("ID et Nom du fichier son obligatoires."); } $stmt = $db->prepare(' UPDATE sounds SET name = :name, site_category = :site, usage_type = :usage, description = :desc WHERE id = :id '); $stmt->execute([ ':name' => $name, ':site' => $siteCategory, ':usage' => $usageType, ':desc' => $description, ':id' => $id, ]); echo json_encode([ 'success' => true, 'message' => "Informations du fichier son mises à jour.", ]); break; case 'delete_sound': $id = (int)($payload['id'] ?? 0); $stmt = $db->prepare('SELECT * FROM sounds WHERE id = :id'); $stmt->execute([':id' => $id]); $sound = $stmt->fetch(); if (!$sound) { throw new InvalidArgumentException("Fichier son introuvable."); } $slug = $sound['slug']; @unlink("/var/lib/asterisk/sounds/custom/{$slug}.wav"); @unlink("/var/lib/asterisk/sounds/custom/{$slug}.alaw"); @unlink("/var/www/nexus-pbx/storage/sounds/{$slug}.mp3"); foreach (glob("/var/lib/asterisk/moh/*/{$slug}.*") ?: [] as $f) { @unlink($f); } $soundDir = "/var/lib/asterisk/moh/sound_{$slug}"; if (is_dir($soundDir)) { foreach (glob("{$soundDir}/*") ?: [] as $f) { @unlink($f); } @rmdir($soundDir); } // Réinitialise les références si ce son était sélectionné par défaut ou sur un trunk $db->prepare("UPDATE settings SET default_moh_sound_id = 0 WHERE default_moh_sound_id = :id")->execute([':id' => $id]); $db->prepare("UPDATE trunks SET moh_sound_id = 0 WHERE moh_sound_id = :id")->execute([':id' => $id]); $db->prepare('DELETE FROM sounds WHERE id = :id')->execute([':id' => $id]); $generator->writeAllConfigs(); try { $ami = AmiClient::fromDatabase($db); $ami->reloadPjsipAndDialplan(); } catch (\Throwable $e) {} echo json_encode([ 'success' => true, 'message' => "Fichier son « {$sound['name']} » supprimé avec succès.", ]); break; case 'save_sound_site': $id = (int)($payload['id'] ?? 0); $name = trim((string)($payload['name'] ?? '')); $description = trim((string)($payload['description'] ?? '')); if ($name === '') { throw new InvalidArgumentException("Le nom de la catégorie de site est obligatoire (ex: Site Nice, Site Paris)."); } if ($id > 0) { // Récupère l'ancien nom pour mettre à jour les sons associés $stmtOld = $db->prepare('SELECT name FROM sound_sites WHERE id = :id'); $stmtOld->execute([':id' => $id]); $oldName = $stmtOld->fetchColumn(); $stmt = $db->prepare('UPDATE sound_sites SET name = :n, description = :d WHERE id = :id'); $stmt->execute([':n' => $name, ':d' => $description, ':id' => $id]); if ($oldName && $oldName !== $name) { $updSounds = $db->prepare('UPDATE sounds SET site_category = :newName WHERE site_category = :oldName'); $updSounds->execute([':newName' => $name, ':oldName' => $oldName]); } } else { $stmt = $db->prepare('INSERT INTO sound_sites (name, description) VALUES (:n, :d)'); $stmt->execute([':n' => $name, ':d' => $description]); } $generator->writeAllConfigs(); try { $ami = AmiClient::fromDatabase($db); $ami->reloadPjsipAndDialplan(); } catch (\Throwable $e) {} echo json_encode([ 'success' => true, 'message' => "Catégorie de site « {$name} » enregistrée.", ]); break; case 'delete_sound_site': $id = (int)($payload['id'] ?? 0); $stmtSite = $db->prepare('SELECT name FROM sound_sites WHERE id = :id'); $stmtSite->execute([':id' => $id]); $siteName = $stmtSite->fetchColumn(); if (!$siteName) { throw new InvalidArgumentException("Catégorie de site introuvable."); } if (strtolower(trim($siteName)) === 'global') { throw new InvalidArgumentException("La catégorie « Global » est le socle standard du système et ne peut pas être supprimée."); } // Réassigne les sons de ce site vers 'Global' $db->prepare("UPDATE sounds SET site_category = 'Global' WHERE site_category = :old")->execute([':old' => $siteName]); $db->prepare('DELETE FROM sound_sites WHERE id = :id')->execute([':id' => $id]); $generator->writeAllConfigs(); try { $ami = AmiClient::fromDatabase($db); $ami->reloadPjsipAndDialplan(); } catch (\Throwable $e) {} echo json_encode([ 'success' => true, 'message' => "Catégorie « {$siteName} » supprimée. Les sons associés ont été réassignés à « Global ».", ]); break; case 'set_default_moh': $soundId = (int)($payload['sound_id'] ?? 0); $stmtSnd = $db->prepare("SELECT * FROM sounds WHERE id = :id"); $stmtSnd->execute([':id' => $soundId]); $snd = $stmtSnd->fetch(); if (!$snd) { throw new InvalidArgumentException("Fichier son introuvable."); } if ($snd['usage_type'] !== 'moh') { throw new InvalidArgumentException("Le fichier sélectionné doit avoir pour type d'usage « Musique d'attente »."); } $db->prepare("UPDATE settings SET default_moh_sound_id = :id WHERE id = 1")->execute([':id' => $soundId]); // Copie vers /var/lib/asterisk/moh/default $defDir = "/var/lib/asterisk/moh/default"; @mkdir($defDir, 0775, true); @chown($defDir, 'www-data'); @chgrp($defDir, 'asterisk'); // Nettoyage des anciens fichiers par défaut foreach (glob("{$defDir}/*") ?: [] as $f) { @unlink($f); } $srcWav = "/var/lib/asterisk/sounds/custom/{$snd['slug']}.wav"; $srcAlaw = "/var/lib/asterisk/sounds/custom/{$snd['slug']}.alaw"; if (file_exists($srcWav)) @copy($srcWav, "{$defDir}/{$snd['slug']}.wav"); if (file_exists($srcAlaw)) @copy($srcAlaw, "{$defDir}/{$snd['slug']}.alaw"); $generator->writeAllConfigs(); try { $ami = AmiClient::fromDatabase($db); $ami->reloadPjsipAndDialplan(); } catch (\Throwable $e) {} echo json_encode([ 'success' => true, 'message' => "« {$snd['name']} » est maintenant la Musique d'attente (MoH) générale par défaut du PBX.", ]); break; case 'save_trunk': $id = isset($payload['id']) && $payload['id'] !== '' ? (int)$payload['id'] : null; $name = trim((string)($payload['name'] ?? '')); $host = trim((string)($payload['host'] ?? '')); $port = max(1, min(65535, (int)($payload['port'] ?? 5060))); $outbound_proxy = trim((string)($payload['outbound_proxy'] ?? '')); $transportRaw = strtolower(trim((string)($payload['transport'] ?? 'udp'))); $transport = in_array($transportRaw, ['udp', 'tcp', 'tls'], true) ? $transportRaw : 'udp'; $auth_mode = ($payload['auth_mode'] ?? 'userpass') === 'ip' ? 'ip' : 'userpass'; $username = trim((string)($payload['username'] ?? '')); $auth_username = trim((string)($payload['auth_username'] ?? '')); $secret = trim((string)($payload['secret'] ?? '')); $rawRegMode = strtolower(trim((string)($payload['registration_mode'] ?? ''))); if (in_array($rawRegMode, ['send', 'receive', 'none'], true)) { $registration_mode = $rawRegMode; } else { $registration_mode = (!empty($payload['register']) && $auth_mode !== 'ip') ? 'send' : 'none'; } $register = ($registration_mode === 'send') ? 1 : 0; $server_uri = trim((string)($payload['server_uri'] ?? '')); $client_uri = trim((string)($payload['client_uri'] ?? '')); $contact_user = trim((string)($payload['contact_user'] ?? '')); $expiration = max(60, min(86400, (int)($payload['expiration'] ?? 3600))); $retry_interval = max(10, min(3600, (int)($payload['retry_interval'] ?? 60))); $line_support = isset($payload['line_support']) ? (int)!empty($payload['line_support']) : 1; $from_user = trim((string)($payload['from_user'] ?? '')); $from_domain = trim((string)($payload['from_domain'] ?? '')); $outbound_callerid = trim((string)($payload['outbound_callerid'] ?? '')); $send_pai = isset($payload['send_pai']) ? (int)!empty($payload['send_pai']) : 1; $send_rpid = (int)!empty($payload['send_rpid']); $send_diversion = isset($payload['send_diversion']) ? (int)!empty($payload['send_diversion']) : 1; $trust_id_inbound = isset($payload['trust_id_inbound']) ? (int)!empty($payload['trust_id_inbound']) : 1; $trust_id_outbound = isset($payload['trust_id_outbound']) ? (int)!empty($payload['trust_id_outbound']) : 1; $max_retries = max(0, (int)($payload['max_retries'] ?? 10000)); $forbidden_retry_interval = max(0, (int)($payload['forbidden_retry_interval'] ?? 600)); $fatal_retry_interval = max(0, (int)($payload['fatal_retry_interval'] ?? 0)); $codecs = preg_replace('/[^a-z0-9,]/', '', strtolower((string)($payload['codecs'] ?? 'alaw,ulaw,g722'))) ?: 'alaw,ulaw'; $dtmf_mode = in_array($payload['dtmf_mode'] ?? '', ['rfc4733', 'inband', 'info', 'auto'], true) ? $payload['dtmf_mode'] : 'rfc4733'; $media_encryption = in_array($payload['media_encryption'] ?? '', ['no', 'sdes', 'dtls'], true) ? $payload['media_encryption'] : 'no'; $nat_symmetric = isset($payload['nat_symmetric']) ? (int)!empty($payload['nat_symmetric']) : 1; $direct_media = (int)!empty($payload['direct_media']); $qualify_frequency = max(0, min(3600, (int)($payload['qualify_frequency'] ?? 60))); $max_channels = max(0, (int)($payload['max_channels'] ?? 0)); $timers = in_array($payload['timers'] ?? '', ['yes', 'no', 'required', 'always'], true) ? $payload['timers'] : 'yes'; $language = preg_replace('/[^a-zA-Z_]/', '', trim((string)($payload['language'] ?? 'fr'))) ?: 'fr'; $rtp_keepalive = max(0, min(3600, (int)($payload['rtp_keepalive'] ?? 15))); $t38_udptl = in_array($payload['t38_udptl'] ?? '', ['no', 'yes', 'nat'], true) ? $payload['t38_udptl'] : 'no'; $fax_detect = (int)!empty($payload['fax_detect']); $inband_progress = (int)!empty($payload['inband_progress']); $match_ips = trim((string)($payload['match_ips'] ?? '')); $context = preg_replace('/[^a-zA-Z0-9_\-]/', '', trim((string)($payload['context'] ?? 'from-external'))) ?: 'from-external'; $inbound_dest = trim((string)($payload['inbound_dest'] ?? 'terminate-hangup')) ?: 'terminate-hangup'; $outbound_prefix = preg_replace('/[^0-9*#X._\[\]\-+]/', '', trim((string)($payload['outbound_prefix'] ?? ''))); $custom_endpoint_params = trim((string)($payload['custom_endpoint_params'] ?? '')); $custom_reg_params = trim((string)($payload['custom_reg_params'] ?? '')); $custom_aor_params = trim((string)($payload['custom_aor_params'] ?? '')); $moh_sound_id = max(0, (int)($payload['moh_sound_id'] ?? 0)); $cid_name_prefix = trim((string)($payload['cid_name_prefix'] ?? '')); $dial_options = preg_replace('/[^a-zA-Z0-9(),]/', '', trim((string)($payload['dial_options'] ?? 'tTR'))) ?: 'tTR'; $enabled = isset($payload['enabled']) ? (int)!empty($payload['enabled']) : 1; $didRoutesRaw = is_array($payload['did_routes'] ?? null) ? $payload['did_routes'] : []; $cleanDidRoutes = ConfigGenerator::normalizeInboundRoutes(json_encode($didRoutesRaw)); $did_routes_json = json_encode($cleanDidRoutes, JSON_UNESCAPED_UNICODE); if ($name === '') { throw new InvalidArgumentException("Le nom du Trunk est requis."); } if ($registration_mode === 'receive') { if ($username === '' || $secret === '') { throw new InvalidArgumentException("En mode Réception (ex: passerelle Grandstream HT813, Cisco SPA...), l'Identifiant (Username) et le Mot de passe SIP sont requis pour authentifier la passerelle."); } } else { if ($host === '') { throw new InvalidArgumentException("L'hôte SIP opérateur (Serveur SIP) est requis."); } } $params = [ ':name' => $name, ':host' => $host, ':port' => $port, ':outbound_proxy' => $outbound_proxy, ':transport' => $transport, ':auth_mode' => $auth_mode, ':username' => $username, ':auth_username' => $auth_username, ':secret' => $secret, ':registration_mode' => $registration_mode, ':register' => $register, ':server_uri' => $server_uri, ':client_uri' => $client_uri, ':contact_user' => $contact_user, ':expiration' => $expiration, ':retry_interval' => $retry_interval, ':max_retries' => $max_retries, ':forbidden_retry_interval' => $forbidden_retry_interval, ':fatal_retry_interval' => $fatal_retry_interval, ':line_support' => $line_support, ':from_user' => $from_user, ':from_domain' => $from_domain, ':outbound_callerid' => $outbound_callerid, ':send_pai' => $send_pai, ':send_rpid' => $send_rpid, ':send_diversion' => $send_diversion, ':trust_id_inbound' => $trust_id_inbound, ':trust_id_outbound' => $trust_id_outbound, ':codecs' => $codecs, ':dtmf_mode' => $dtmf_mode, ':media_encryption' => $media_encryption, ':nat_symmetric' => $nat_symmetric, ':direct_media' => $direct_media, ':qualify_frequency' => $qualify_frequency, ':max_channels' => $max_channels, ':timers' => $timers, ':language' => $language, ':rtp_keepalive' => $rtp_keepalive, ':t38_udptl' => $t38_udptl, ':fax_detect' => $fax_detect, ':inband_progress' => $inband_progress, ':match_ips' => $match_ips, ':context' => $context, ':inbound_dest' => $inbound_dest, ':did_routes_json' => $did_routes_json, ':outbound_prefix' => $outbound_prefix, ':custom_endpoint_params' => $custom_endpoint_params, ':custom_reg_params' => $custom_reg_params, ':custom_aor_params' => $custom_aor_params, ':moh_sound_id' => $moh_sound_id, ':cid_name_prefix' => $cid_name_prefix, ':dial_options' => $dial_options, ':enabled' => $enabled, ]; if ($id) { $params[':id'] = $id; $stmt = $db->prepare(' UPDATE trunks SET name = :name, host = :host, port = :port, outbound_proxy = :outbound_proxy, transport = :transport, auth_mode = :auth_mode, username = :username, auth_username = :auth_username, secret = :secret, registration_mode = :registration_mode, register = :register, server_uri = :server_uri, client_uri = :client_uri, contact_user = :contact_user, expiration = :expiration, retry_interval = :retry_interval, max_retries = :max_retries, forbidden_retry_interval = :forbidden_retry_interval, fatal_retry_interval = :fatal_retry_interval, line_support = :line_support, from_user = :from_user, from_domain = :from_domain, outbound_callerid = :outbound_callerid, send_pai = :send_pai, send_rpid = :send_rpid, send_diversion = :send_diversion, trust_id_inbound = :trust_id_inbound, trust_id_outbound = :trust_id_outbound, codecs = :codecs, dtmf_mode = :dtmf_mode, media_encryption = :media_encryption, nat_symmetric = :nat_symmetric, direct_media = :direct_media, qualify_frequency = :qualify_frequency, max_channels = :max_channels, timers = :timers, language = :language, rtp_keepalive = :rtp_keepalive, t38_udptl = :t38_udptl, fax_detect = :fax_detect, inband_progress = :inband_progress, match_ips = :match_ips, context = :context, inbound_dest = :inbound_dest, did_routes_json = :did_routes_json, outbound_prefix = :outbound_prefix, custom_endpoint_params = :custom_endpoint_params, custom_reg_params = :custom_reg_params, custom_aor_params = :custom_aor_params, moh_sound_id = :moh_sound_id, cid_name_prefix = :cid_name_prefix, dial_options = :dial_options, enabled = :enabled WHERE id = :id '); $stmt->execute($params); } else { $stmt = $db->prepare(' INSERT INTO trunks ( name, host, port, outbound_proxy, transport, auth_mode, username, auth_username, secret, registration_mode, register, server_uri, client_uri, contact_user, expiration, retry_interval, max_retries, forbidden_retry_interval, fatal_retry_interval, line_support, from_user, from_domain, outbound_callerid, send_pai, send_rpid, send_diversion, trust_id_inbound, trust_id_outbound, codecs, dtmf_mode, media_encryption, nat_symmetric, direct_media, qualify_frequency, max_channels, timers, language, rtp_keepalive, t38_udptl, fax_detect, inband_progress, match_ips, context, inbound_dest, did_routes_json, outbound_prefix, custom_endpoint_params, custom_reg_params, custom_aor_params, moh_sound_id, cid_name_prefix, dial_options, enabled ) VALUES ( :name, :host, :port, :outbound_proxy, :transport, :auth_mode, :username, :auth_username, :secret, :registration_mode, :register, :server_uri, :client_uri, :contact_user, :expiration, :retry_interval, :max_retries, :forbidden_retry_interval, :fatal_retry_interval, :line_support, :from_user, :from_domain, :outbound_callerid, :send_pai, :send_rpid, :send_diversion, :trust_id_inbound, :trust_id_outbound, :codecs, :dtmf_mode, :media_encryption, :nat_symmetric, :direct_media, :qualify_frequency, :max_channels, :timers, :language, :rtp_keepalive, :t38_udptl, :fax_detect, :inband_progress, :match_ips, :context, :inbound_dest, :did_routes_json, :outbound_prefix, :custom_endpoint_params, :custom_reg_params, :custom_aor_params, :moh_sound_id, :cid_name_prefix, :dial_options, :enabled ) ON CONFLICT(name) DO UPDATE SET host = excluded.host, port = excluded.port, outbound_proxy = excluded.outbound_proxy, transport = excluded.transport, auth_mode = excluded.auth_mode, username = excluded.username, auth_username = excluded.auth_username, secret = excluded.secret, registration_mode = excluded.registration_mode, register = excluded.register, server_uri = excluded.server_uri, client_uri = excluded.client_uri, contact_user = excluded.contact_user, expiration = excluded.expiration, retry_interval = excluded.retry_interval, max_retries = excluded.max_retries, forbidden_retry_interval = excluded.forbidden_retry_interval, fatal_retry_interval = excluded.fatal_retry_interval, line_support = excluded.line_support, from_user = excluded.from_user, from_domain = excluded.from_domain, outbound_callerid = excluded.outbound_callerid, send_pai = excluded.send_pai, send_rpid = excluded.send_rpid, send_diversion = excluded.send_diversion, trust_id_inbound = excluded.trust_id_inbound, trust_id_outbound = excluded.trust_id_outbound, codecs = excluded.codecs, dtmf_mode = excluded.dtmf_mode, media_encryption = excluded.media_encryption, nat_symmetric = excluded.nat_symmetric, direct_media = excluded.direct_media, qualify_frequency = excluded.qualify_frequency, max_channels = excluded.max_channels, timers = excluded.timers, language = excluded.language, rtp_keepalive = excluded.rtp_keepalive, t38_udptl = excluded.t38_udptl, fax_detect = excluded.fax_detect, inband_progress = excluded.inband_progress, match_ips = excluded.match_ips, context = excluded.context, inbound_dest = excluded.inbound_dest, did_routes_json = excluded.did_routes_json, outbound_prefix = excluded.outbound_prefix, custom_endpoint_params = excluded.custom_endpoint_params, custom_reg_params = excluded.custom_reg_params, custom_aor_params = excluded.custom_aor_params, moh_sound_id = excluded.moh_sound_id, cid_name_prefix = excluded.cid_name_prefix, dial_options = excluded.dial_options, enabled = excluded.enabled '); $stmt->execute($params); } $generator->writeAllConfigs(); try { $ami = AmiClient::fromDatabase($db); $ami->reloadPjsipAndDialplan(); } catch (\Throwable $e) {} $displayHost = $host !== '' ? "{$host}:{$port}" : "Passerelle locale / FXO"; echo json_encode([ 'success' => true, 'message' => "Trunk SIP « {$name} » ({$displayHost}) sauvegardé avec tous ses paramètres PJSIP.", ]); break; case 'delete_trunk': $id = (int)($payload['id'] ?? 0); $db->prepare('DELETE FROM trunks WHERE id = :id')->execute([':id' => $id]); $generator->writeAllConfigs(); try { $ami = AmiClient::fromDatabase($db); $ami->reloadPjsipAndDialplan(); } catch (\Throwable $e) {} echo json_encode([ 'success' => true, 'message' => 'Trunk SIP supprimé.', ]); break; case 'save_ivr': $id = isset($payload['id']) && $payload['id'] !== '' ? (int)$payload['id'] : null; $name = trim((string)($payload['name'] ?? '')); $description = trim((string)($payload['description'] ?? '')); $audio_file = trim((string)($payload['audio_file'] ?? '')); $timeout = max(2, min(60, (int)($payload['timeout'] ?? 10))); $direct_dial = isset($payload['direct_dial']) ? (int)!empty($payload['direct_dial']) : 1; $invalid_retries = max(1, min(10, (int)($payload['invalid_retries'] ?? 3))); $invalid_sound_id = max(0, (int)($payload['invalid_sound_id'] ?? 0)); $invalid_dest = trim((string)($payload['invalid_dest'] ?? 'hangup')) ?: 'hangup'; $timeout_retries = max(1, min(10, (int)($payload['timeout_retries'] ?? 2))); $timeout_sound_id = max(0, (int)($payload['timeout_sound_id'] ?? 0)); $timeout_dest = trim((string)($payload['timeout_dest'] ?? 'hangup')) ?: 'hangup'; $options = is_array($payload['options'] ?? null) ? $payload['options'] : []; if ($name === '') { throw new InvalidArgumentException("Le nom du serveur vocal (IVR) est obligatoire."); } if ($audio_file === '') { $audio_file = 'demo-instruct'; } $cleanOptions = []; foreach ($options as $digit => $dest) { $d = preg_replace('/[^0-9*#]/', '', (string)$digit); $v = trim((string)$dest); if ($d !== '' && $v !== '') { $cleanOptions[$d] = $v; } } $params = [ ':name' => $name, ':desc' => $description, ':audio' => $audio_file, ':timeout' => $timeout, ':options' => json_encode($cleanOptions, JSON_UNESCAPED_UNICODE), ':direct_dial' => $direct_dial, ':invalid_retries' => $invalid_retries, ':invalid_sound_id' => $invalid_sound_id, ':invalid_dest' => $invalid_dest, ':timeout_retries' => $timeout_retries, ':timeout_sound_id' => $timeout_sound_id, ':timeout_dest' => $timeout_dest, ]; if ($id) { $params[':id'] = $id; $stmt = $db->prepare(' UPDATE ivrs SET name = :name, description = :desc, audio_file = :audio, timeout = :timeout, options_json = :options, direct_dial = :direct_dial, invalid_retries = :invalid_retries, invalid_sound_id = :invalid_sound_id, invalid_dest = :invalid_dest, timeout_retries = :timeout_retries, timeout_sound_id = :timeout_sound_id, timeout_dest = :timeout_dest WHERE id = :id '); $stmt->execute($params); } else { $stmt = $db->prepare(' INSERT INTO ivrs ( name, description, audio_file, timeout, options_json, direct_dial, invalid_retries, invalid_sound_id, invalid_dest, timeout_retries, timeout_sound_id, timeout_dest ) VALUES ( :name, :desc, :audio, :timeout, :options, :direct_dial, :invalid_retries, :invalid_sound_id, :invalid_dest, :timeout_retries, :timeout_sound_id, :timeout_dest ) ON CONFLICT(name) DO UPDATE SET description = excluded.description, audio_file = excluded.audio_file, timeout = excluded.timeout, options_json = excluded.options_json, direct_dial = excluded.direct_dial, invalid_retries = excluded.invalid_retries, invalid_sound_id = excluded.invalid_sound_id, invalid_dest = excluded.invalid_dest, timeout_retries = excluded.timeout_retries, timeout_sound_id = excluded.timeout_sound_id, timeout_dest = excluded.timeout_dest '); $stmt->execute($params); } $generator->writeAllConfigs(); try { $ami = AmiClient::fromDatabase($db); $ami->reloadPjsipAndDialplan(); } catch (\Throwable $e) {} echo json_encode([ 'success' => true, 'message' => "SVI / IVR « {$name} » enregistré avec " . count($cleanOptions) . " touche(s) et configuré dans Asterisk.", ]); break; case 'delete_ivr': $id = (int)($payload['id'] ?? 0); $db->prepare('DELETE FROM ivrs WHERE id = :id')->execute([':id' => $id]); $generator->writeAllConfigs(); try { $ami = AmiClient::fromDatabase($db); $ami->reloadPjsipAndDialplan(); } catch (\Throwable $e) {} echo json_encode([ 'success' => true, 'message' => 'Serveur vocal interactif supprimé.', ]); break; case 'save_outbound_route': $id = isset($payload['id']) && $payload['id'] !== '' ? (int)$payload['id'] : null; $name = trim((string)($payload['name'] ?? '')); $priority = max(1, (int)($payload['priority'] ?? 10)); $outbound_callerid = trim((string)($payload['outbound_callerid'] ?? '')); $override_ext_cid = (int)!empty($payload['override_ext_cid']); $route_password = preg_replace('/[^0-9*#]/', '', trim((string)($payload['route_password'] ?? ''))); $route_type = in_array($payload['route_type'] ?? '', ['normal', 'emergency', 'intracompany'], true) ? (string)$payload['route_type'] : 'normal'; $moh_class = trim((string)($payload['moh_class'] ?? 'default')) ?: 'default'; $permission_mode = ($payload['permission_mode'] ?? 'all') === 'restricted' ? 'restricted' : 'all'; $continue_next_route = isset($payload['continue_next_route']) ? (int)!empty($payload['continue_next_route']) : 1; $congestion_dest = trim((string)($payload['congestion_dest'] ?? 'terminate-congestion')) ?: 'terminate-congestion'; $record_mode = in_array($payload['record_mode'] ?? '', ['force', 'yes', 'dontcare', 'no', 'never'], true) ? (string)$payload['record_mode'] : 'dontcare'; $enabled = isset($payload['enabled']) ? (int)!empty($payload['enabled']) : 1; if ($name === '') { throw new InvalidArgumentException("Le nom de la Route Sortante est obligatoire (ex: National, Mobile, Secours, Emergency)."); } $trunkSeqRaw = is_array($payload['trunk_sequence'] ?? null) ? $payload['trunk_sequence'] : []; $cleanTrunks = []; foreach ($trunkSeqRaw as $tName) { $tName = trim((string)$tName); if ($tName !== '' && !in_array($tName, $cleanTrunks, true)) { $cleanTrunks[] = $tName; } } if (empty($cleanTrunks)) { throw new InvalidArgumentException("Veuillez sélectionner au moins un Trunk SIP dans la séquence de la route."); } $patternsRaw = is_array($payload['dial_patterns'] ?? null) ? $payload['dial_patterns'] : []; $cleanPatterns = []; foreach ($patternsRaw as $pat) { if (!is_array($pat)) { continue; } $mp = trim((string)($pat['match_pattern'] ?? '')); if ($mp === '') { continue; } $cleanPatterns[] = [ 'prepend' => preg_replace('/[^0-9*#+]/', '', trim((string)($pat['prepend'] ?? ''))), 'prefix' => preg_replace('/[^0-9*#+]/', '', trim((string)($pat['prefix'] ?? ''))), 'match_pattern' => $mp, 'callerid' => trim((string)($pat['callerid'] ?? '')), ]; } if (empty($cleanPatterns)) { throw new InvalidArgumentException("Ajoutez au moins un masque de numérotation (Dial Pattern), par exemple 0[1-59]XXXXXXXX ou X."); } $allowGrpsRaw = is_array($payload['allowed_groups'] ?? null) ? $payload['allowed_groups'] : []; $cleanGrps = array_values(array_unique(array_filter(array_map(fn($x) => trim((string)$x), $allowGrpsRaw)))); $allowExtsRaw = is_array($payload['allowed_extensions'] ?? null) ? $payload['allowed_extensions'] : []; $cleanExts = array_values(array_unique(array_filter(array_map(fn($x) => trim((string)$x), $allowExtsRaw)))); $params = [ ':priority' => $priority, ':name' => $name, ':cid' => $outbound_callerid, ':override_cid' => $override_ext_cid, ':pwd' => $route_password, ':rtype' => $route_type, ':moh' => $moh_class, ':trunks' => json_encode($cleanTrunks), ':patterns' => json_encode($cleanPatterns), ':pmode' => $permission_mode, ':agroups' => json_encode($cleanGrps), ':aexts' => json_encode($cleanExts), ':cont' => $continue_next_route, ':cdest' => $congestion_dest, ':rec' => $record_mode, ':enabled' => $enabled, ]; if ($id) { $params[':id'] = $id; $stmt = $db->prepare(' UPDATE outbound_routes SET priority = :priority, name = :name, outbound_callerid = :cid, override_ext_cid = :override_cid, route_password = :pwd, route_type = :rtype, moh_class = :moh, trunk_sequence_json = :trunks, dial_patterns_json = :patterns, permission_mode = :pmode, allowed_groups_json = :agroups, allowed_extensions_json = :aexts, continue_next_route = :cont, congestion_dest = :cdest, record_mode = :rec, enabled = :enabled WHERE id = :id '); $stmt->execute($params); } else { $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, :enabled ) '); $stmt->execute($params); } echo json_encode([ 'success' => true, 'message' => "Route Sortante « {$name} » (Priorité #{$priority}) enregistrée avec " . count($cleanTrunks) . " trunk(s) et " . count($cleanPatterns) . " masque(s).", ]); break; case 'delete_outbound_route': $id = (int)($payload['id'] ?? 0); $db->prepare('DELETE FROM outbound_routes WHERE id = :id')->execute([':id' => $id]); echo json_encode([ 'success' => true, 'message' => 'Route Sortante supprimée.', ]); break; case 'reorder_outbound_route': $id = (int)($payload['id'] ?? 0); $direction = ($payload['direction'] ?? 'up') === 'down' ? 'down' : 'up'; $routes = $db->query('SELECT id, priority FROM outbound_routes ORDER BY priority ASC, id ASC')->fetchAll(); $idx = null; foreach ($routes as $k => $r) { if ((int)$r['id'] === $id) { $idx = $k; break; } } if ($idx !== null) { $swapIdx = $direction === 'up' ? $idx - 1 : $idx + 1; if (isset($routes[$swapIdx])) { $tmp = $routes[$idx]; $routes[$idx] = $routes[$swapIdx]; $routes[$swapIdx] = $tmp; } $upd = $db->prepare('UPDATE outbound_routes SET priority = :p WHERE id = :id'); foreach ($routes as $newOrder => $r) { $upd->execute([':p' => $newOrder + 1, ':id' => (int)$r['id']]); } } echo json_encode([ 'success' => true, 'message' => 'Ordre de priorité des Routes Sortantes mis à jour.', ]); break; case 'save_company_branding': $company_name = trim((string)($payload['company_name'] ?? '')); $company_logo = trim((string)($payload['company_logo'] ?? '')); if ($company_logo !== '' && !preg_match('/^data:image\/(png|jpeg|jpg|webp|svg\+xml|gif);base64,/i', $company_logo)) { throw new InvalidArgumentException("Format d'image invalide (utilisez PNG, SVG, JPG ou WebP)."); } if (strlen($company_logo) > 2 * 1024 * 1024) { throw new InvalidArgumentException("Le logo est trop volumineux (max 1.5 Mo)."); } if (array_key_exists('company_name', $payload) && array_key_exists('company_logo', $payload)) { $stmt = $db->prepare('UPDATE settings SET company_name = :cn, company_logo = :cl, updated_at = CURRENT_TIMESTAMP WHERE id = 1'); $stmt->execute([':cn' => $company_name, ':cl' => $company_logo]); } elseif (array_key_exists('company_logo', $payload)) { $stmt = $db->prepare('UPDATE settings SET company_logo = :cl, updated_at = CURRENT_TIMESTAMP WHERE id = 1'); $stmt->execute([':cl' => $company_logo]); } elseif (array_key_exists('company_name', $payload)) { $stmt = $db->prepare('UPDATE settings SET company_name = :cn, updated_at = CURRENT_TIMESTAMP WHERE id = 1'); $stmt->execute([':cn' => $company_name]); } echo json_encode([ 'success' => true, 'message' => $company_logo === '' && !array_key_exists('company_name', $payload) ? 'Logo entreprise réinitialisé.' : 'Logo et identité entreprise mis à jour sur NexusPBX !', ]); break; case 'save_settings': $domain = trim((string)($payload['domain'] ?? 'pbx.local')); $company_name = trim((string)($payload['company_name'] ?? '')); $external_ip = trim((string)($payload['external_ip'] ?? '')); $local_net = trim((string)($payload['local_net'] ?? '192.168.1.0/24')); $udp_port = max(1024, min(65535, (int)($payload['udp_port'] ?? 5060))); $tls_port = max(1024, min(65535, (int)($payload['tls_port'] ?? 5061))); $letsencrypt_domain = trim((string)($payload['letsencrypt_domain'] ?? '')); $ami_host = trim((string)($payload['ami_host'] ?? '127.0.0.1')); $ami_port = max(1, min(65535, (int)($payload['ami_port'] ?? 5038))); $ami_user = trim((string)($payload['ami_user'] ?? 'pbxgui')); $ami_secret = trim((string)($payload['ami_secret'] ?? '')); $wizard_completed = isset($payload['wizard_completed']) ? (int)!empty($payload['wizard_completed']) : 1; $tls_verify_client = isset($payload['tls_verify_client']) ? (int)!empty($payload['tls_verify_client']) : 0; $system_language = in_array(trim((string)($payload['system_language'] ?? 'fr')), ['fr', 'en'], true) ? trim((string)$payload['system_language']) : 'fr'; if ($ami_secret === '') { throw new InvalidArgumentException("Le mot de passe AMI ne peut pas être vide."); } $stmt = $db->prepare(' UPDATE settings SET domain = :domain, company_name = :company_name, external_ip = :external_ip, local_net = :local_net, udp_port = :udp_port, tls_port = :tls_port, letsencrypt_domain = :le_domain, ami_host = :ami_host, ami_port = :ami_port, ami_user = :ami_user, ami_secret = :ami_secret, wizard_completed = :wiz, tls_verify_client = :tvc, system_language = :syslang, updated_at = CURRENT_TIMESTAMP WHERE id = 1 '); $stmt->execute([ ':domain' => $domain, ':company_name' => $company_name, ':external_ip' => $external_ip, ':local_net' => $local_net, ':udp_port' => $udp_port, ':tls_port' => $tls_port, ':le_domain' => $letsencrypt_domain, ':ami_host' => $ami_host, ':ami_port' => $ami_port, ':ami_user' => $ami_user, ':ami_secret' => $ami_secret, ':wiz' => $wizard_completed, ':tvc' => $tls_verify_client, ':syslang' => $system_language, ]); SystemChecker::ensureAsteriskIncludes(); if (!empty($payload['new_admin_password'])) { $newPass = (string)$payload['new_admin_password']; if (strlen($newPass) < 6) { throw new InvalidArgumentException("Le nouveau mot de passe administrateur doit faire au moins 6 caractères."); } Auth::updatePassword($db, Auth::getUsername(), $newPass); } echo json_encode([ 'success' => true, 'message' => 'Paramètres globaux enregistrés dans SQLite.', ]); break; case 'test_ami': $ami = AmiClient::fromDatabase($db); $result = $ami->testConnection(); $pjsip = $ami->runCommand('pjsip show endpoints'); echo json_encode([ 'success' => true, 'message' => 'Liaison AMI opérationnelle : ' . ($result['version'] ?: $result['banner']), 'version' => $result['version'], 'endpoints' => $pjsip, ]); break; case 'apply_config': $writeStats = $generator->writeAllConfigs(); $ami = AmiClient::fromDatabase($db); $reloadInfo = $ami->reloadPjsipAndDialplan(); // Synchronisation automatique temps réel vers le nœud pair si Cluster actif et Master $clusterSyncMsg = null; try { $cm = new ClusterManager($db); $cSet = $cm->getSettings(); $isVipActive = $cm->isVirtualIpActive($cSet['virtual_ip'] ?? ''); if (!empty($cSet['enabled']) && !empty($cSet['auto_sync']) && $isVipActive && !empty($cSet['peer_ip'])) { $syncRes = $cm->pushSyncToPeer(); $clusterSyncMsg = $syncRes['message'] ?? 'Réplication pair OK'; } } catch (\Throwable $ce) { error_log("[CLUSTER] Auto-sync on apply_config error: " . $ce->getMessage()); $clusterSyncMsg = "Alerte réplication pair : " . $ce->getMessage(); } echo json_encode([ 'success' => true, 'message' => 'Configurations appliquées avec succès !' . ($clusterSyncMsg ? ' (' . $clusterSyncMsg . ')' : ''), 'details' => [ 'files' => $writeStats, 'reload' => $reloadInfo, 'cluster_sync' => $clusterSyncMsg, ], ]); break; case 'force_register_trunk': $regName = trim((string)($payload['reg_name'] ?? '*all')) ?: '*all'; if ($regName !== '*all' && !preg_match('/^[a-zA-Z0-9_-]+$/', $regName)) { throw new InvalidArgumentException("Identifiant de registration invalide."); } $ami = AmiClient::fromDatabase($db); $out = $ami->forceRegister($regName); echo json_encode([ 'success' => true, 'message' => "Requête SIP REGISTER envoyée immédiatement ({$regName}) ! NexusPBX a repris la ligne opérateur.", 'output' => $out, ]); break; case 'save_custom_cert': $name = trim((string)($payload['name'] ?? ($_POST['name'] ?? ''))); $certPem = trim((string)($payload['cert_pem'] ?? ($_POST['cert_pem'] ?? ''))); $keyPem = trim((string)($payload['key_pem'] ?? ($_POST['key_pem'] ?? ''))); $chainPem = trim((string)($payload['chain_pem'] ?? ($_POST['chain_pem'] ?? ''))); // Support upload direct de fichiers .crt, .pem, .key if (!empty($_FILES['cert_file']['tmp_name']) && is_uploaded_file($_FILES['cert_file']['tmp_name'])) { $certPem = (string)file_get_contents($_FILES['cert_file']['tmp_name']); } if (!empty($_FILES['key_file']['tmp_name']) && is_uploaded_file($_FILES['key_file']['tmp_name'])) { $keyPem = (string)file_get_contents($_FILES['key_file']['tmp_name']); } if (!empty($_FILES['chain_file']['tmp_name']) && is_uploaded_file($_FILES['chain_file']['tmp_name'])) { $chainPem = (string)file_get_contents($_FILES['chain_file']['tmp_name']); } $makeDefault = !empty($payload['is_default']) || (!empty($_POST['is_default']) && (string)$_POST['is_default'] === '1'); $cert = CertificateManager::saveCustomCert($db, $name, $certPem, $keyPem, $chainPem, $makeDefault); if ($makeDefault) { $generator->writeAllConfigs(); try { $ami = AmiClient::fromDatabase($db); $ami->reloadPjsipAndDialplan(); } catch (\Throwable) {} exec('sudo /usr/bin/systemctl restart asterisk 2>/dev/null || true'); exec('sudo /usr/bin/systemctl reload nginx 2>/dev/null || true'); } echo json_encode([ 'success' => true, 'message' => 'Certificat SSL/TLS importé avec succès ! Pairage de clé vérifié.', 'cert' => $cert, ]); break; case 'request_letsencrypt_cert': $name = trim((string)($payload['name'] ?? '')); $domain = trim((string)($payload['domain'] ?? '')); $email = trim((string)($payload['email'] ?? '')); $makeDefault = !empty($payload['is_default']); $staging = !empty($payload['staging']); $cert = CertificateManager::requestLetsEncrypt($db, $name, $domain, $email, $makeDefault, $staging); if ($makeDefault) { $generator->writeAllConfigs(); try { $ami = AmiClient::fromDatabase($db); $ami->reloadPjsipAndDialplan(); } catch (\Throwable) {} exec('sudo /usr/bin/systemctl restart asterisk 2>/dev/null || true'); exec('sudo /usr/bin/systemctl reload nginx 2>/dev/null || true'); } echo json_encode([ 'success' => true, 'message' => "Certificat Let's Encrypt émis avec succès pour {$domain} !", 'cert' => $cert, ]); break; case 'set_default_cert': $id = (int)($payload['id'] ?? 0); if ($id <= 0) { throw new InvalidArgumentException("ID de certificat manquant."); } CertificateManager::setDefault($db, $id); $generator->writeAllConfigs(); try { $ami = AmiClient::fromDatabase($db); $ami->reloadPjsipAndDialplan(); } catch (\Throwable) {} exec('sudo /usr/bin/systemctl restart asterisk 2>/dev/null || true'); exec('sudo /usr/bin/systemctl reload nginx 2>/dev/null || true'); echo json_encode([ 'success' => true, 'message' => 'Certificat actif défini par défaut pour le transport PJSIP TLS (5061) et SRTP.', ]); break; case 'delete_cert': $id = (int)($payload['id'] ?? 0); if ($id <= 0) { throw new InvalidArgumentException("ID de certificat manquant."); } CertificateManager::delete($db, $id); echo json_encode([ 'success' => true, 'message' => 'Certificat supprimé avec succès.', ]); break; case 'renew_cert': $id = (int)($payload['id'] ?? 0); if ($id <= 0) { throw new InvalidArgumentException("ID de certificat manquant."); } $cert = CertificateManager::renew($db, $id); if ((int)$cert['is_default'] === 1) { $generator->writeAllConfigs(); try { $ami = AmiClient::fromDatabase($db); $ami->reloadPjsipAndDialplan(); } catch (\Throwable) {} } echo json_encode([ 'success' => true, 'message' => "Certificat renouvelé avec succès (expire le {$cert['valid_to']}) !", 'cert' => $cert, ]); break; case 'set_tls_security': $tlsVerifyClient = !empty($payload['tls_verify_client']) ? 1 : 0; $stmt = $db->prepare('UPDATE settings SET tls_verify_client = :tvc, updated_at = CURRENT_TIMESTAMP WHERE id = 1'); $stmt->execute([':tvc' => $tlsVerifyClient]); $generator->writeAllConfigs(); try { $ami = AmiClient::fromDatabase($db); $ami->reloadPjsipAndDialplan(); } catch (\Throwable) {} echo json_encode([ 'success' => true, 'message' => $tlsVerifyClient === 1 ? 'Mode Mutual TLS (mTLS) activé : certificat client X.509 désormais requis sur le port PJSIP TLS 5061.' : 'Mode Mutual TLS désactivé : vérification standard par mot de passe SIP dans le tunnel TLS.', ]); break; case 'detect_external_ip': $detectedIp = ''; $sources = [ 'https://api.ipify.org', 'https://ifconfig.me/ip', 'https://icanhazip.com', ]; foreach ($sources as $url) { $ctx = stream_context_create(['http' => ['timeout' => 3, 'user_agent' => 'curl/7.88.1']]); $res = @file_get_contents($url, false, $ctx); if ($res) { $candidate = trim($res); if (filter_var($candidate, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) { $detectedIp = $candidate; break; } } } echo json_encode([ 'success' => true, 'ip' => $detectedIp, ]); break; case 'save_sip_settings': $externalIp = trim((string)($payload['external_ip'] ?? '')); $externalUdpPort = max(0, min(65535, (int)($payload['external_udp_port'] ?? 0))); $externalTlsPort = max(0, min(65535, (int)($payload['external_tls_port'] ?? 0))); $udpPort = max(1024, min(65535, (int)($payload['udp_port'] ?? 5060))); $tlsPort = max(1024, min(65535, (int)($payload['tls_port'] ?? 5061))); $rtpStart = max(1024, min(65530, (int)($payload['rtp_start'] ?? 10000))); $rtpEnd = max($rtpStart + 4, min(65535, (int)($payload['rtp_end'] ?? 20000))); $rtpChecksums = !empty($payload['rtp_checksums']) ? 1 : 0; $rtpStrict = !empty($payload['rtp_strict']) ? 1 : 0; $rtpTimeout = max(5, (int)($payload['rtp_timeout'] ?? 30)); $rtpKeepalive = max(0, (int)($payload['rtp_keepalive'] ?? 15)); $allowAnonymousSip = !empty($payload['allow_anonymous_sip']) ? 1 : 0; // Réseaux locaux (array) $localNets = $payload['local_networks'] ?? []; if (!is_array($localNets)) { $localNets = array_filter(array_map('trim', explode("\n", (string)$localNets))); } $cleanNets = []; foreach ($localNets as $net) { $net = trim((string)$net); if ($net !== '') { $cleanNets[] = $net; } } if (empty($cleanNets)) { $cleanNets = ['10.10.0.0/16', '192.168.1.0/24']; } $localNetworksJson = json_encode(array_values(array_unique($cleanNets))); $stmt = $db->prepare(' UPDATE settings SET external_ip = :ext_ip, external_udp_port = :ext_udp, external_tls_port = :ext_tls, udp_port = :udp_port, tls_port = :tls_port, local_networks_json = :local_nets, local_net = :first_net, rtp_start = :rtp_start, rtp_end = :rtp_end, rtp_checksums = :rtp_checksums, rtp_strict = :rtp_strict, rtp_timeout = :rtp_timeout, rtp_keepalive = :rtp_keepalive, allow_anonymous_sip = :anon, updated_at = CURRENT_TIMESTAMP WHERE id = 1 '); $stmt->execute([ ':ext_ip' => $externalIp, ':ext_udp' => $externalUdpPort, ':ext_tls' => $externalTlsPort, ':udp_port' => $udpPort, ':tls_port' => $tlsPort, ':local_nets' => $localNetworksJson, ':first_net' => $cleanNets[0] ?? '192.168.1.0/24', ':rtp_start' => $rtpStart, ':rtp_end' => $rtpEnd, ':rtp_checksums' => $rtpChecksums, ':rtp_strict' => $rtpStrict, ':rtp_timeout' => $rtpTimeout, ':rtp_keepalive' => $rtpKeepalive, ':anon' => $allowAnonymousSip, ]); $writeStats = $generator->writeAllConfigs(); $reloadInfo = []; try { $ami = AmiClient::fromDatabase($db); $reloadInfo = $ami->reloadPjsipAndDialplan(); } catch (\Throwable $e) { $reloadInfo['error'] = $e->getMessage(); } echo json_encode([ 'success' => true, 'message' => 'Paramètres SIP & NAT enregistrés et appliqués à Asterisk PJSIP & RTP !', 'details' => [ 'files' => $writeStats, 'reload' => $reloadInfo, ], ]); break; case 'toggle_feature_code': $codeId = (int)($payload['id'] ?? 0); $enabled = !empty($payload['enabled']) ? 1 : 0; if ($codeId <= 0) { throw new InvalidArgumentException("ID de code de fonction manquant."); } $stmt = $db->prepare('UPDATE feature_codes SET enabled = :en, updated_at = CURRENT_TIMESTAMP WHERE id = :id'); $stmt->execute([':en' => $enabled, ':id' => $codeId]); $writeStats = $generator->writeAllConfigs(); $reloadInfo = []; try { $ami = AmiClient::fromDatabase($db); $reloadInfo = $ami->reloadPjsipAndDialplan(); } catch (\Throwable $e) { $reloadInfo['error'] = $e->getMessage(); } echo json_encode([ 'success' => true, 'message' => 'Code de fonction ' . ($enabled ? 'activé' : 'désactivé') . ' et dialplan rechargé !', 'details' => ['files' => $writeStats, 'reload' => $reloadInfo], ]); break; case 'update_feature_code': $codeId = (int)($payload['id'] ?? 0); $newCode = trim((string)($payload['code'] ?? '')); if ($codeId <= 0 || $newCode === '') { throw new InvalidArgumentException("Code ou ID invalide."); } $stmt = $db->prepare('UPDATE feature_codes SET code = :code, updated_at = CURRENT_TIMESTAMP WHERE id = :id'); $stmt->execute([':code' => $newCode, ':id' => $codeId]); $writeStats = $generator->writeAllConfigs(); $reloadInfo = []; try { $ami = AmiClient::fromDatabase($db); $reloadInfo = $ami->reloadPjsipAndDialplan(); } catch (\Throwable $e) { $reloadInfo['error'] = $e->getMessage(); } echo json_encode([ 'success' => true, 'message' => "Code de fonction mis à jour ({$newCode}) et dialplan rechargé !", 'details' => ['files' => $writeStats, 'reload' => $reloadInfo], ]); break; case 'reset_feature_codes': $db->exec('UPDATE feature_codes SET code = default_code, enabled = 1, updated_at = CURRENT_TIMESTAMP'); $writeStats = $generator->writeAllConfigs(); $reloadInfo = []; try { $ami = AmiClient::fromDatabase($db); $reloadInfo = $ami->reloadPjsipAndDialplan(); } catch (\Throwable $e) { $reloadInfo['error'] = $e->getMessage(); } echo json_encode([ 'success' => true, 'message' => 'Tous les codes de fonctions ont été réinitialisés aux valeurs standard FreePBX/Asterisk !', 'details' => ['files' => $writeStats, 'reload' => $reloadInfo], ]); break; case 'get_backup_data': $backupSettings = BackupManager::getBackupSettings(); $backups = BackupManager::listBackups(); echo json_encode([ 'success' => true, 'settings' => $backupSettings, 'backups' => $backups, ]); break; case 'save_backup_settings': BackupManager::saveBackupSettings($payload); echo json_encode([ 'success' => true, 'message' => 'Paramètres de sauvegarde enregistrés avec succès !', 'settings' => BackupManager::getBackupSettings(), ]); break; case 'test_ssh_backup': $testRes = BackupManager::testSshConnection($payload); echo json_encode($testRes); break; case 'create_backup': $res = BackupManager::createBackup('manual'); echo json_encode([ 'success' => true, 'message' => 'Sauvegarde complète du système générée avec succès !', 'data' => $res, ]); break; case 'list_backups': $backups = BackupManager::listBackups(); echo json_encode([ 'success' => true, 'backups' => $backups, ]); break; case 'delete_backup': $filename = trim((string)($payload['filename'] ?? '')); if ($filename === '') { throw new InvalidArgumentException("Nom de fichier manquant."); } $deleted = BackupManager::deleteBackup($filename); echo json_encode([ 'success' => $deleted, 'message' => $deleted ? 'Sauvegarde supprimée avec succès.' : 'Impossible de supprimer cette sauvegarde.', ]); break; case 'restore_backup': $filename = trim((string)($payload['filename'] ?? '')); if ($filename === '') { throw new InvalidArgumentException("Nom de fichier manquant."); } $localPath = BackupManager::BACKUP_DIR . '/' . basename($filename); $tempDownload = false; if (!file_exists($localPath)) { $bSettings = BackupManager::getBackupSettings(); if ($bSettings['backup_storage_type'] !== 'ssh' || empty($bSettings['backup_ssh_host'])) { throw new RuntimeException("Sauvegarde introuvable localement et aucun accès SSH configuré."); } $remoteFile = rtrim($bSettings['backup_ssh_remote_path'], '/') . '/' . basename($filename); $localPath = '/tmp/restore_' . uniqid() . '_' . basename($filename); // Utiliser la méthode de téléchargement depuis SSH $ref = new \ReflectionClass(BackupManager::class); $fetchMethod = $ref->getMethod('fetchFromSsh'); $fetchMethod->setAccessible(true); $fRes = $fetchMethod->invoke(null, $remoteFile, $localPath, $bSettings); if (!$fRes['success'] || !file_exists($localPath)) { throw new RuntimeException("Impossible de rapatrier l'archive distante : " . ($fRes['message'] ?? 'Erreur SCP')); } $tempDownload = true; } try { $res = BackupManager::restoreBackup($localPath); echo json_encode($res); } finally { if ($tempDownload && file_exists($localPath)) { @unlink($localPath); } } break; case 'upload_restore_backup': if (empty($_FILES['backup_file']['tmp_name']) || !is_uploaded_file($_FILES['backup_file']['tmp_name'])) { $errCode = $_FILES['backup_file']['error'] ?? null; $errMessages = [ UPLOAD_ERR_INI_SIZE => "L'archive dépasse la taille maximale autorisée par le serveur (upload_max_filesize).", UPLOAD_ERR_FORM_SIZE => "L'archive dépasse la taille maximale spécifiée dans le formulaire.", UPLOAD_ERR_PARTIAL => "Le fichier de sauvegarde n'a été que partiellement téléversé.", UPLOAD_ERR_NO_FILE => "Aucun fichier de sauvegarde n'a été sélectionné.", UPLOAD_ERR_NO_TMP_DIR => "Dossier temporaire manquant sur le serveur.", UPLOAD_ERR_CANT_WRITE => "Échec d'écriture du fichier temporaire sur le disque.", UPLOAD_ERR_EXTENSION => "Une extension PHP a arrêté le téléversement de l'archive.", ]; $msg = $errMessages[$errCode] ?? "Aucun fichier de sauvegarde valide n'a été téléversé (erreur de transfert ou fichier trop volumineux)."; throw new InvalidArgumentException($msg); } $origName = $_FILES['backup_file']['name'] ?? 'backup.tar.gz'; if (!str_ends_with(strtolower($origName), '.tar.gz') && !str_ends_with(strtolower($origName), '.tgz')) { throw new InvalidArgumentException("Format d'archive non supporté. Veuillez sélectionner un fichier .tar.gz"); } $tmpUploadPath = '/tmp/upload_restore_' . uniqid() . '.tar.gz'; if (!move_uploaded_file($_FILES['backup_file']['tmp_name'], $tmpUploadPath)) { throw new RuntimeException("Échec du déplacement du fichier téléversé."); } try { $res = BackupManager::restoreBackup($tmpUploadPath); echo json_encode($res); } finally { if (file_exists($tmpUploadPath)) { @unlink($tmpUploadPath); } } break; case 'download_backup': $filename = trim((string)($_GET['filename'] ?? '')); if ($filename === '') { http_response_code(400); die("Paramètre filename manquant."); } BackupManager::downloadBackup($filename); exit; case 'get_firewall_status': $status = FirewallManager::getStatus(); echo json_encode([ 'success' => true, 'data' => $status, ]); break; case 'save_firewall_settings': $res = FirewallManager::saveSettings($payload); echo json_encode($res); break; case 'unban_ip': $ip = trim((string)($payload['ip'] ?? '')); $jail = trim((string)($payload['jail'] ?? '')); $res = FirewallManager::unbanIp($ip, $jail); echo json_encode($res); break; case 'ban_ip': $ip = trim((string)($payload['ip'] ?? '')); $jail = trim((string)($payload['jail'] ?? 'asterisk')); $res = FirewallManager::banIp($ip, $jail); echo json_encode($res); break; case 'get_cdr': $filters = [ 'limit' => (int)($payload['limit'] ?? $_GET['limit'] ?? 100), 'direction' => (string)($payload['direction'] ?? $_GET['direction'] ?? 'all'), 'disposition' => (string)($payload['disposition'] ?? $_GET['disposition'] ?? 'all'), 'period' => (string)($payload['period'] ?? $_GET['period'] ?? 'all'), 'search' => (string)($payload['search'] ?? $_GET['search'] ?? ''), ]; $cdrData = CdrManager::getCalls($db, $filters); echo json_encode([ 'success' => true, 'calls' => $cdrData['calls'], 'stats' => $cdrData['stats'], 'total_records' => $cdrData['total_records'], ]); break; case 'export_cdr_csv': $filters = [ 'direction' => (string)($_GET['direction'] ?? 'all'), 'disposition' => (string)($_GET['disposition'] ?? 'all'), 'period' => (string)($_GET['period'] ?? 'all'), 'search' => (string)($_GET['search'] ?? ''), ]; $csv = CdrManager::exportCsv($db, $filters); header('Content-Type: text/csv; charset=utf-8'); header('Content-Disposition: attachment; filename="nexuspbx_journal_appels_' . date('Y-m-d_His') . '.csv"'); header('Pragma: no-cache'); header('Expires: 0'); echo $csv; exit; case 'clear_cdr': $cleared = CdrManager::clearCdr(); echo json_encode([ 'success' => $cleared, 'message' => $cleared ? "Journal d'appels réinitialisé avec succès." : "Impossible de vider le fichier CDR.", ]); break; case 'check_update': $appDir = dirname(__DIR__); clearstatcache(true, "{$appDir}/version.json"); $localVerJson = file_exists("{$appDir}/version.json") ? json_decode((string)file_get_contents("{$appDir}/version.json"), true) : ['version' => '1.3.8', 'changelog' => []]; $currentVer = $localVerJson['version'] ?? '1.3.8'; $remoteVer = $currentVer; $remoteChangelog = []; $remoteDate = ''; // 1. Interrogation directe via HTTP Raw avec cache-busting strict (bypass OpenResty/Cloudflare max-age) $ts = time(); $rawHttp = @shell_exec("curl -k -s -H 'Cache-Control: no-cache, no-store' -H 'Pragma: no-cache' --connect-timeout 4 \"https://git.sorcier-research.com/sorcier/NexusPBX/raw/branch/main/version.json?_t={$ts}\" 2>/dev/null"); if (empty($rawHttp)) { $ctx = stream_context_create([ 'http' => [ 'timeout' => 4, 'header' => "Cache-Control: no-cache\r\nPragma: no-cache\r\n" ], 'ssl' => ['verify_peer' => false, 'verify_peer_name' => false] ]); $rawHttp = @file_get_contents("https://git.sorcier-research.com/sorcier/NexusPBX/raw/branch/main/version.json?_t={$ts}", false, $ctx); } if (!empty($rawHttp)) { $remoteData = json_decode((string)$rawHttp, true); if (is_array($remoteData) && !empty($remoteData['version'])) { $remoteVer = (string)$remoteData['version']; $remoteChangelog = $remoteData['changelog'] ?? []; $remoteDate = $remoteData['release_date'] ?? ''; } } else { // 2. Fallback local Git si indisponibilité du serveur HTTP exec("git --git-dir={$appDir}/.git fetch origin main --quiet 2>/dev/null", $output, $code); $remoteJsonRaw = shell_exec("git --git-dir={$appDir}/.git show origin/main:version.json 2>/dev/null"); if ($remoteJsonRaw) { $remoteData = json_decode($remoteJsonRaw, true); if (is_array($remoteData) && !empty($remoteData['version'])) { $remoteVer = (string)$remoteData['version']; $remoteChangelog = $remoteData['changelog'] ?? []; $remoteDate = $remoteData['release_date'] ?? ''; } } } $hasUpdate = version_compare($remoteVer, $currentVer, '>'); echo json_encode([ 'success' => true, 'current_version' => $currentVer, 'latest_version' => $remoteVer, 'has_update' => $hasUpdate, 'changelog' => $hasUpdate ? $remoteChangelog : ($localVerJson['changelog'] ?? []), 'release_date' => $remoteDate ?: ($localVerJson['release_date'] ?? ''), ]); break; case 'run_update': $output = []; $code = 0; exec("sudo /var/www/nexus-pbx/scripts/update.sh 2>&1", $output, $code); $outputStr = implode("\n", $output); $appDir = dirname(__DIR__); clearstatcache(true, "{$appDir}/version.json"); if (function_exists('opcache_reset')) { @opcache_reset(); } $newVerJson = file_exists("{$appDir}/version.json") ? json_decode((string)file_get_contents("{$appDir}/version.json"), true) : ['version' => '1.4.3']; while (ob_get_level() > 0) { @ob_end_clean(); } header('Content-Type: application/json; charset=utf-8'); header('Cache-Control: no-cache, no-store, must-revalidate'); echo json_encode([ 'success' => ($code === 0), 'new_version' => $newVerJson['version'] ?? '1.4.3', 'output' => $outputStr, 'message' => ($code === 0) ? "Mise à jour réussie !" : "Erreur lors de la mise à jour", ]); if (function_exists('fastcgi_finish_request')) { fastcgi_finish_request(); } break; case 'cluster_status': $cm = new ClusterManager($db); echo json_encode([ 'success' => true, 'status' => $cm->getClusterStatus(), ]); break; case 'cluster_interfaces': $cm = new ClusterManager($db); echo json_encode([ 'success' => true, 'interfaces' => $cm->detectNetworkInterfaces(), ]); break; case 'cluster_save': $cm = new ClusterManager($db); $res = $cm->saveSettings($payload); echo json_encode($res); break; case 'cluster_sync_now': $cm = new ClusterManager($db); $res = $cm->pushSyncToPeer(); echo json_encode($res); break; case 'cluster_switchover': $cm = new ClusterManager($db); $res = $cm->triggerSwitchover(); echo json_encode($res); break; default: http_response_code(400); echo json_encode([ 'success' => false, 'message' => "Action AJAX inconnue : {$action}", ]); } } catch (Throwable $e) { http_response_code(422); echo json_encode([ 'success' => false, 'message' => $e->getMessage(), ]); }