commit c5de8d0c71cf7ee548457ea9ae2134c11c291675 Author: sorcier Date: Mon Nov 2 02:24:47 2026 +0100 Initial commit - NexusPBX IP Telephony Platform diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..abbd33b --- /dev/null +++ b/.gitignore @@ -0,0 +1,38 @@ +# NexusPBX - Git Ignore Rules + +# Bases de données SQLite locales (contient mots de passe, clés, tokens) +/storage/*.sqlite +/storage/*.sqlite-shm +/storage/*.sqlite-wal + +# Données dynamiques et métriques +/storage/metrics_prev.json +/storage/*.json + +# Sauvegardes +/storage/backups/* +!/storage/backups/.gitkeep +/storage/system_backups/* +!/storage/system_backups/.gitkeep + +# Sons et enregistrements personnalisés téléversés +/storage/sounds/* +!/storage/sounds/.gitkeep + +# Avatars et médias téléversés +/public/avatars/* +!/public/avatars/.gitkeep + +# Clés et certificats SSL privés +*.key +*.pem +*.crt + +# Fichiers temporaires et caches système / éditeurs +.DS_Store +Thumbs.db +*.swp +*.tmp +.idea/ +.vscode/ + diff --git a/README.md b/README.md new file mode 100644 index 0000000..7069df7 --- /dev/null +++ b/README.md @@ -0,0 +1,99 @@ +# NexusPBX - Système de Téléphonie IP Moderne & Souverain + +**NexusPBX** est une plateforme de communication d'entreprise tout-en-un, moderne, ultra-légère et hautement sécurisée, construite au-dessus d'**Asterisk (PJSIP)**, **Nginx**, **PHP 8.4** et **SQLite 3**. + +--- + +## 🚀 Fonctionnalités Clés + +### 1. Gestion Avancée des Postes (PJSIP) +- **Multi-profils de transport** : + - **UDP Interne** (Réseau local d'entreprise, faible latence). + - **TLS + SRTP Chiffré** (Postes distants et nomades avec chiffrement de la signalisation et de la voix AES-128). + - **WebRTC WSS** (Téléphonie navigateur temps réel). +- **Détection dynamique en temps réel** : Analyse des contacts PJSIP en direct (transport actif négocié, User-Agent du téléphone, latence RTT, statut de joignabilité). + +### 2. Annuaire d'Entreprise & XML Téléphones IP (Yealink) +- Synchronisation automatique en temps réel avec les extensions PBX (numéro, nom, email, groupe). +- Prise en charge des contacts externes (fournisseurs, clients, partenaires). +- Gestion des avatars et photos de contacts. +- **Endpoint XML Yealink officiel** : + - `http://:8080/phonebook.php?group=` (ou `?group=all`) + - Génération conforme `` avec ``, ``, ``, ``, ``. + +### 3. Trunks SIP & Routage +- Trunks SIP sortants (Orange Business, OVH Telecom, Free Pro, etc.). +- Routes sortantes avec sélection de préfixes et restriction par groupes d'extensions (Sites / Agences). +- Routes entrantes (DID / SDA) avec redirection intelligente vers Postes, Groupes de sonnerie ou SVI. + +### 4. Serveur Vocal Interactif (SVI / IVR) & Groupes +- Menu vocal interactif avec gestion des touches DTMF (0-9, \*, #). +- Gestion des musiques d'attente (MoH) et téléversement de fichiers audio. +- Groupes de sonnerie simultanés ou séquentiels. + +### 5. Sécurité Renforcée & Pare-feu PBX +- **Mode Strict Port 80** : Le port 80 standard est hermétiquement verrouillé et exclusivement réservé aux challenges ACME Let's Encrypt Certbot (retour 404 sur tout le reste). +- **Port d'administration HTTPS dédié** (ex: port 8443) avec certificats SSL Let's Encrypt automatisés. +- **Fail2ban unifié** : Jails Fail2ban actives pour les attaques SIP (PJSIP brute-force, scan SIPVicious) et attaques Web (recherche d'exploits HTTP). + +### 6. Sauvegardes & Restauration +- Sauvegardes complètes à chaud (Base de données, configurations Asterisk, fichiers sons, règles pare-feu). +- Stockage local ou export automatique distant via **SSH / SCP / SFTP**. +- Restauration en un clic. + +--- + +## 📂 Architecture du Projet + +```text +nexus-pbx/ +├── public/ # Racine Web Nginx +│ ├── index.php # Interface Web SPA moderne +│ ├── api.php # API RESTful backend +│ ├── phonebook.php # Générateur XML Yealink Remote Phonebook +│ ├── assets/ # JavaScript (app.js), styles Tailwind CSS +│ └── avatars/ # Photos des contacts de l'annuaire +├── src/ # Classes PHP d'orchestration +│ ├── AmiClient.php # Client Asterisk Manager Interface (AMI) +│ ├── Auth.php # Authentification & sessions +│ ├── BackupManager.php # Moteur de sauvegarde locale & distante SSH +│ ├── CertificateManager.php # Gestionnaire SSL & Certbot +│ ├── ConfigGenerator.php # Générateur dynamique de conf Asterisk (pjsip, extensions) +│ ├── Database.php # Connexion PDO SQLite +│ ├── FirewallManager.php # Moteur Pare-feu & règles Nginx +│ ├── PhonebookManager.php # Moteur d'annuaire et export XML +│ ├── SystemChecker.php # Diagnostic de santé système +│ └── SystemMetrics.php # Monitoring CPU, RAM, Disque, Asterisk +├── scripts/ # Scripts CLI et daemons système +│ ├── setup-debian.sh # Script d'installation automatisé Debian 12 / 13 +│ ├── nexus-firewall # Script de gestion iptables/nftables +│ └── nexus_autobackup.php# Cronjob de sauvegarde automatique +└── storage/ # Données persistantes (exclues du Git) + ├── database.sqlite # Base de données applicative + ├── backups/ # Archives de sauvegarde + └── sounds/ # Fichiers audio MoH et annonces +``` + +--- + +## ⚙️ Prérequis & Installation + +- **Système d'exploitation** : Debian 12 (Bookworm) ou Debian 13 (Trixie). +- **Paquets requis** : Asterisk 20+, PHP 8.2+, Nginx, SQLite3, Fail2ban, Certbot. + +Pour déployer sur une machine fraîche : +```bash +sudo bash scripts/setup-debian.sh +``` + +--- + +## 🔒 Confidentialité & Sécurité du Dépôt + +Ce dépôt de code source exclut rigoureusement : +- Les bases de données SQLite contenant les identifiants locaux. +- Les clés privées et certificats SSL. +- Les enregistrements vocaux et sauvegardes d'entreprise. + +--- +*Développé pour les télécoms d'entreprise souveraines.* diff --git a/public/api.php b/public/api.php new file mode 100644 index 0000000..cfb1080 --- /dev/null +++ b/public/api.php @@ -0,0 +1,2068 @@ + 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(); + $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 ($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']]; + } + + echo json_encode([ + 'success' => true, + 'user' => Auth::getUsername(), + 'settings' => $settings, + 'extensions' => $extensions, + 'groups' => $groups, + '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/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); + + $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); + 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, + ]); + } + + 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]); + echo json_encode([ + 'success' => true, + 'message' => "Groupe de sonnerie 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"; + + // 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($targetWav)); + exec($cmdWav, $outWav, $retWav); + if ($retWav !== 0) { + 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($targetAlaw)); + 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. 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($targetWav, "{$siteMohDir}/{$slug}.wav"); + @copy($targetAlaw, "{$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($targetWav)); + $durStr = trim((string)shell_exec($cmdDur)); + $durationSec = max(1, (int)round((float)$durStr)); + + // Permissions + @chown($targetWav, 'www-data'); @chgrp($targetWav, 'asterisk'); + @chown($targetAlaw, 'www-data'); @chgrp($targetAlaw, 'asterisk'); + @chown($targetMp3, 'www-data'); @chgrp($targetMp3, 'asterisk'); + + $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'] ?? '')); + $register = ($auth_mode === 'ip') ? 0 : (int)!empty($payload['register']); + $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; + $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'] ?? 'ivr-accueil')) ?: '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)); + $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 === '' || $host === '') { + throw new InvalidArgumentException("Le nom du Trunk et l'hôte SIP opérateur sont 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, + ':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, + ':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, + ':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, 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, 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, + 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, + 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, 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, enabled + ) VALUES ( + :name, :host, :port, :outbound_proxy, :transport, :auth_mode, :username, :auth_username, :secret, + :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, :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, :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, 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, 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, + enabled = excluded.enabled + '); + $stmt->execute($params); + } + + echo json_encode([ + 'success' => true, + 'message' => "Trunk SIP « {$name} » ({$host}:{$port}) 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]); + 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: OVH, Livebox, 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(); + + echo json_encode([ + 'success' => true, + 'message' => 'Configurations écrites, pile PJSIP rechargée et Re-Register SIP envoyé à tous les opérateurs !', + 'details' => [ + 'files' => $writeStats, + 'reload' => $reloadInfo, + ], + ]); + 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) {} + } + + 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) {} + } + + 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) {} + + 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'])) { + throw new InvalidArgumentException("Aucun fichier de sauvegarde valide n'a été téléversé."); + } + + $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; + + 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(), + ]); +} + diff --git a/public/assets/app.js b/public/assets/app.js new file mode 100644 index 0000000..fd9c952 --- /dev/null +++ b/public/assets/app.js @@ -0,0 +1,5242 @@ +'use strict'; + +let appState = { + settings: {}, + extensions: [], + groups: [], + extension_groups: [], + trunks: [], + ivrs: [], + sounds: [], + sound_sites: [], + feature_codes: [], + certificates: [], + active_certificate: null, + diagnostics: { healthy: false, checks: [] }, + metrics: null, + previews: { pjsip: '', extensions: '', moh: '' } +}; + +const MAX_HISTORY_POINTS = 24; +const chartHistory = { + cpu: Array(MAX_HISTORY_POINTS).fill(2), + ram: Array(MAX_HISTORY_POINTS).fill(15), + rx: Array(MAX_HISTORY_POINTS).fill(0.5), + tx: Array(MAX_HISTORY_POINTS).fill(0.2) +}; + +let metricsTimer = null; + +// --- Notifications Toast Tailwind --- +function showToast(message, type = 'success') { + const container = document.getElementById('toast-container'); + if (!container) return; + + const colors = { + success: 'bg-emerald-600/95 border-emerald-400/40 text-white', + error: 'bg-red-600/95 border-red-400/40 text-white', + info: 'bg-indigo-600/95 border-indigo-400/40 text-white' + }; + + const toast = document.createElement('div'); + toast.className = `pointer-events-auto flex items-start justify-between gap-3 px-4 py-3 rounded-xl border shadow-2xl backdrop-blur text-xs sm:text-sm font-medium transition-all duration-300 transform translate-y-2 opacity-0 ${colors[type] || colors.info}`; + toast.innerHTML = ` +
${escapeHtml(message)}
+ + `; + + toast.querySelector('button').addEventListener('click', () => toast.remove()); + container.appendChild(toast); + + requestAnimationFrame(() => { + toast.classList.remove('translate-y-2', 'opacity-0'); + }); + + setTimeout(() => { + toast.classList.add('opacity-0', 'translate-x-4'); + setTimeout(() => toast.remove(), 300); + }, 4200); +} + +function escapeHtml(str) { + return String(str ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +// --- Client AJAX Fetch API --- +async function apiCall(action, payload = null) { + const options = { + method: payload ? 'POST' : 'GET', + headers: { 'Accept': 'application/json' } + }; + if (payload) { + options.headers['Content-Type'] = 'application/json'; + options.body = JSON.stringify(payload); + } + + const res = await fetch(`/api.php?action=${encodeURIComponent(action)}`, options); + const cType = res.headers.get('content-type') || ''; + let data; + if (cType.includes('application/json')) { + data = await res.json(); + } else { + const rawText = await res.text(); + if (res.status === 413) { + throw new Error("Le contenu envoyé est trop volumineux pour le serveur (Limite 64 Mo)."); + } + throw new Error(`Erreur serveur (${res.status}) : ${rawText.replace(/<[^>]*>/g, '').trim().slice(0, 150) || 'Réponse inattendue'}`); + } + + if (res.status === 401 && data.unauthenticated) { + stopMetricsPolling(); + document.getElementById('app-screen').classList.add('hidden'); + document.getElementById('login-screen').classList.remove('hidden'); + throw new Error(data.message || 'Session expirée'); + } + + if (!res.ok || !data.success) { + throw new Error(data.message || 'Erreur lors de la requête AJAX'); + } + + return data; +} + +// --- Chargement global de l'état --- +async function loadState() { + try { + const data = await apiCall('get_state'); + appState = data; + if (data.metrics) { + pushMetricsToHistory(data.metrics); + } + renderAll(); + loadBackups(); + startMetricsPolling(); + } catch (err) { + if (err.message !== 'Session expirée') { + showToast(err.message, 'error'); + } + } +} + +async function pollMetrics() { + try { + const data = await apiCall('get_metrics'); + if (data.metrics) { + appState.metrics = data.metrics; + pushMetricsToHistory(data.metrics); + renderDashboardMetrics(); + renderExtensionsTable(); + renderTrunksTable(); + } + } catch (_) { + // Silencieux en arrière-plan + } +} + +function startMetricsPolling() { + if (!metricsTimer) { + metricsTimer = setInterval(pollMetrics, 3000); + } +} + +function stopMetricsPolling() { + if (metricsTimer) { + clearInterval(metricsTimer); + metricsTimer = null; + } +} + +function pushMetricsToHistory(m) { + chartHistory.cpu.push(Number(m.cpu?.percent || 0)); + chartHistory.cpu.shift(); + + chartHistory.ram.push(Number(m.memory?.percent || 0)); + chartHistory.ram.shift(); + + chartHistory.rx.push(Number(m.network?.rx_kbps || 0)); + chartHistory.rx.shift(); + + chartHistory.tx.push(Number(m.network?.tx_kbps || 0)); + chartHistory.tx.shift(); +} + +function renderAll() { + renderCompanyBranding(); + renderSummaryCards(); + renderDashboardMetrics(); + renderExtensionsTable(); + renderGroupsTable(); + renderExtensionGroupsTable(); + renderPhonebookTable(); + renderTrunksTable(); + renderOutboundRoutesTable(); + renderIvrsTable(); + renderSoundsTable(); + renderFeatureCodes(); + renderSipSettings(); + renderCertificates(); + renderSettingsAndDiagnostics(); +} + +function renderCompanyBranding() { + const s = appState.settings || {}; + const logo = (s.company_logo || '').trim(); + const name = (s.company_name || '').trim(); + + const emptyBox = document.getElementById('sidebar-company-empty'); + const filledBox = document.getElementById('sidebar-company-filled'); + const logoImg = document.getElementById('sidebar-company-logo'); + const nameEl = document.getElementById('sidebar-company-name'); + const slot = document.getElementById('sidebar-company-slot'); + + const loginBox = document.getElementById('login-company-box'); + const loginLogo = document.getElementById('login-company-logo'); + const loginName = document.getElementById('login-company-name'); + + // Aperçu dans l'administration (Onglet Système & Paramètres) + const settingsPreviewImg = document.getElementById('settings-company-preview-img'); + const settingsPreviewEmpty = document.getElementById('settings-company-preview-empty'); + if (settingsPreviewImg && settingsPreviewEmpty) { + if (logo) { + settingsPreviewImg.src = logo; + settingsPreviewImg.classList.remove('hidden'); + settingsPreviewEmpty.classList.add('hidden'); + } else { + settingsPreviewImg.src = ''; + settingsPreviewImg.classList.add('hidden'); + settingsPreviewEmpty.classList.remove('hidden'); + } + } + + if (logo || name) { + emptyBox?.classList.add('hidden'); + filledBox?.classList.remove('hidden'); + + if (logo && logoImg) { + logoImg.src = logo; + logoImg.classList.remove('hidden'); + } else if (logoImg) { + logoImg.src = ''; + logoImg.classList.add('hidden'); + } + + if (name && nameEl) { + nameEl.textContent = name; + nameEl.classList.remove('hidden'); + } else if (nameEl) { + nameEl.textContent = ''; + nameEl.classList.add('hidden'); + } + + if (loginBox) { + loginBox.classList.remove('hidden'); + if (logo && loginLogo) { + loginLogo.src = logo; + loginLogo.classList.remove('hidden'); + } else if (loginLogo) { + loginLogo.classList.add('hidden'); + } + if (loginName) loginName.textContent = name; + } + } else { + emptyBox?.classList.remove('hidden'); + filledBox?.classList.add('hidden'); + if (logoImg) { logoImg.src = ''; logoImg.classList.add('hidden'); } + if (nameEl) { nameEl.textContent = ''; nameEl.classList.add('hidden'); } + loginBox?.classList.add('hidden'); + } +} + +function triggerCompanyLogoPicker() { + document.getElementById('company-logo-file-input')?.click(); +} + +function handleCompanyLogoUpload(input) { + const file = input?.files?.[0]; + if (!file) return; + + if (file.size > 1500000) { + showToast('Le fichier logo ne doit pas dépasser 1.5 Mo.', 'error'); + input.value = ''; + return; + } + + const reader = new FileReader(); + reader.onload = async (ev) => { + const dataUrl = String(ev.target?.result || ''); + try { + const res = await apiCall('save_company_branding', { company_logo: dataUrl }); + showToast(res.message, 'success'); + await loadState(); + } catch (err) { + showToast(err.message, 'error'); + } finally { + input.value = ''; + } + }; + reader.readAsDataURL(file); +} + +async function clearCompanyLogo() { + try { + const res = await apiCall('save_company_branding', { company_logo: '' }); + showToast(res.message, 'info'); + await loadState(); + } catch (err) { + showToast(err.message, 'error'); + } +} + +function renderSummaryCards() { + const exts = appState.extensions || []; + const grps = appState.groups || []; + const trks = appState.trunks || []; + const ivrs = appState.ivrs || []; + const obRoutes = appState.outbound_routes || []; + + const tlsCount = exts.filter(e => e.transport === 'tls').length; + const udpCount = exts.length - tlsCount; + + document.getElementById('stat-extensions').textContent = exts.length; + document.getElementById('stat-extensions-sub').textContent = `${udpCount} Interne UDP • ${tlsCount} TLS/SRTP`; + document.getElementById('stat-groups').textContent = grps.length; + document.getElementById('stat-trunks').textContent = trks.length; + document.getElementById('stat-ivrs').textContent = ivrs.length; + + // Badges menu gauche + document.getElementById('nav-badge-extensions').textContent = exts.length; + document.getElementById('nav-badge-groups').textContent = grps.length; + const egBadge = document.getElementById('nav-badge-extgroups'); + if (egBadge) egBadge.textContent = (appState.extension_groups || []).length; + const pbBadge = document.getElementById('nav-badge-phonebook'); + if (pbBadge) pbBadge.textContent = (appState.phonebook_contacts || []).length; + document.getElementById('nav-badge-trunks').textContent = trks.length; + const obBadge = document.getElementById('nav-badge-outbound'); + if (obBadge) obBadge.textContent = obRoutes.length; + document.getElementById('nav-badge-ivrs').textContent = ivrs.length; + const soundsBadge = document.getElementById('nav-badge-sounds'); + if (soundsBadge) soundsBadge.textContent = (appState.sounds || []).length; + const backupsBadge = document.getElementById('nav-badge-backups'); + if (backupsBadge && typeof appState.backups_count !== 'undefined') { + backupsBadge.textContent = appState.backups_count; + } + + const diag = appState.diagnostics || { healthy: false, checks: [] }; + const healthEl = document.getElementById('stat-health'); + const amiDot = document.getElementById('ami-dot'); + const sideBadge = document.getElementById('sidebar-health-badge'); + + if (diag.healthy) { + healthEl.textContent = 'AMI & PJSIP OK'; + healthEl.className = 'text-[11px] font-bold text-emerald-400'; + amiDot.className = 'w-2 h-2 rounded-full bg-emerald-400'; + sideBadge.textContent = 'EN LIGNE'; + sideBadge.className = 'px-2 py-0.5 rounded-full text-[10px] font-bold bg-emerald-500/20 text-emerald-300 border border-emerald-500/30'; + } else { + healthEl.textContent = 'Vérifier Config'; + healthEl.className = 'text-[11px] font-bold text-amber-400'; + amiDot.className = 'w-2 h-2 rounded-full bg-amber-400'; + sideBadge.textContent = 'ATTENTION'; + sideBadge.className = 'px-2 py-0.5 rounded-full text-[10px] font-bold bg-amber-500/20 text-amber-300 border border-amber-500/30'; + } + + const lastApplied = appState.settings?.last_applied_at; + document.getElementById('last-applied-label').textContent = lastApplied + ? `Dernière application Asterisk : ${lastApplied} UTC` + : `Cliquez sur « Appliquer les configurations » pour synchroniser Asterisk`; +} + +// --- Rendu Dashboard Métriques & Graphes SVG --- +function renderDashboardMetrics() { + const m = appState.metrics; + if (!m) return; + + // Sidebar infos + const osVerEl = document.getElementById('sidebar-os-version'); + if (osVerEl && m.os_version) osVerEl.textContent = m.os_version; + const astVerEl = document.getElementById('sidebar-ast-version'); + if (astVerEl && m.asterisk?.version) astVerEl.textContent = m.asterisk.version; + document.getElementById('sidebar-ip').textContent = m.network?.ip || '10.10.175.204'; + document.getElementById('sidebar-uptime').textContent = m.uptime || '—'; + document.getElementById('sidebar-online-phones').textContent = `${m.asterisk?.online_contacts ?? 0} / ${(appState.extensions || []).length}`; + + // 1. CPU + const cpuPct = Number(m.cpu?.percent || 0); + document.getElementById('metric-cpu-pct').textContent = `${cpuPct.toFixed(1)}%`; + document.getElementById('metric-cpu-cores').textContent = `${m.cpu?.cores || 1} cœur(s)`; + document.getElementById('metric-cpu-model').textContent = m.cpu?.model || 'Processeur Debian'; + document.getElementById('metric-cpu-load').textContent = `${m.cpu?.load_1 ?? 0} / ${m.cpu?.load_5 ?? 0} / ${m.cpu?.load_15 ?? 0}`; + document.getElementById('bar-cpu').style.width = `${Math.max(3, Math.min(100, cpuPct))}%`; + + // 2. RAM + const ramPct = Number(m.memory?.percent || 0); + document.getElementById('metric-ram-pct').textContent = `${ramPct.toFixed(1)}%`; + document.getElementById('metric-ram-used').textContent = `${m.memory?.used_mb || 0} / ${m.memory?.total_mb || 0} Mo`; + document.getElementById('metric-ram-free').textContent = `Disponible : ${m.memory?.free_mb || 0} Mo`; + document.getElementById('metric-swap').textContent = `${m.memory?.swap_used_mb || 0} / ${m.memory?.swap_total_mb || 0} Mo`; + document.getElementById('bar-ram').style.width = `${Math.max(3, Math.min(100, ramPct))}%`; + + // 3. Réseau + document.getElementById('metric-net-rx').textContent = `${Number(m.network?.rx_kbps || 0).toFixed(1)} Ko/s`; + document.getElementById('metric-net-tx').textContent = `${Number(m.network?.tx_kbps || 0).toFixed(1)} Ko/s`; + document.getElementById('metric-net-iface').textContent = `${m.network?.interface || 'eth0'} (${m.network?.ip || ''})`; + document.getElementById('metric-net-totals').textContent = `Total : ↓ ${m.network?.rx_total_mb || 0} Mo • ↑ ${m.network?.tx_total_mb || 0} Mo`; + + // 4. Disque HDD + const diskPct = Number(m.disk?.percent || 0); + document.getElementById('metric-disk-pct').textContent = `${diskPct.toFixed(1)}%`; + document.getElementById('metric-disk-used').textContent = `${m.disk?.used_gb || 0} / ${m.disk?.total_gb || 0} Go`; + document.getElementById('metric-disk-free').textContent = `Espace libre sur / : ${m.disk?.free_gb || 0} Go`; + document.getElementById('bar-disk').style.width = `${Math.max(3, Math.min(100, diskPct))}%`; + + // 5. Jauges Anneaux (Donuts) + updateDonut('donut-cpu', 'donut-cpu-label', cpuPct); + updateDonut('donut-ram', 'donut-ram-label', ramPct); + updateDonut('donut-disk', 'donut-disk-label', diskPct); + + document.getElementById('dash-active-calls').textContent = m.asterisk?.active_calls ?? 0; + document.getElementById('dash-online-contacts').textContent = `${m.asterisk?.online_contacts ?? 0} / ${(appState.extensions || []).length}`; + + // 6. Graphes SVG Temps Réel + drawDualAreaChart('chart-cpu-ram', chartHistory.cpu, chartHistory.ram, 100, '#818cf8', '#34d399', '%'); + const maxNet = Math.max(10, ...chartHistory.rx, ...chartHistory.tx) * 1.25; + drawDualAreaChart('chart-network', chartHistory.rx, chartHistory.tx, maxNet, '#22d3ee', '#c084fc', 'Ko/s'); + + // 7. Table des téléphones SIP en direct sur le Dashboard + renderDashboardPhonesTable(); + + // 8. Sécurité, Pare-feu, Fail2ban & IPs bloquées + if (m.security) { + const sec = m.security; + const fwDot = document.getElementById('dash-fw-dot'); + const fwStatus = document.getElementById('dash-fw-status'); + const fwDetail = document.getElementById('dash-fw-detail'); + + const isFwActive = Boolean(sec.firewall_enabled || sec.strict_port80) && Boolean(sec.fail2ban_running); + if (isFwActive) { + if (fwDot) fwDot.className = 'w-2 h-2 rounded-full bg-emerald-400 shadow-sm shadow-emerald-500/50'; + if (fwStatus) { + fwStatus.textContent = 'Actif'; + fwStatus.className = 'text-sm font-extrabold text-emerald-400 mt-1'; + } + if (fwDetail) fwDetail.textContent = sec.strict_port80 ? 'Mode Strict (Port 80 Certbot)' : `Port HTTPS : ${sec.admin_port || 8443}`; + } else { + if (fwDot) fwDot.className = 'w-2 h-2 rounded-full bg-slate-500'; + if (fwStatus) { + fwStatus.textContent = 'Inactif'; + fwStatus.className = 'text-sm font-extrabold text-slate-400 mt-1'; + } + if (fwDetail) fwDetail.textContent = !sec.fail2ban_running ? 'Moteur Fail2ban arrêté' : 'Tout le trafic autorisé'; + } + + const f2bDot = document.getElementById('dash-f2b-dot'); + const f2bStatus = document.getElementById('dash-f2b-status'); + const f2bDetail = document.getElementById('dash-f2b-detail'); + + if (sec.fail2ban_running) { + if (f2bDot) f2bDot.className = 'w-2 h-2 rounded-full bg-emerald-400 animate-pulse'; + if (f2bStatus) { + f2bStatus.textContent = 'En ligne'; + f2bStatus.className = 'text-sm font-extrabold text-emerald-400 mt-1'; + } + const activeJailCount = Object.keys(sec.jails || {}).length; + if (f2bDetail) f2bDetail.textContent = `${activeJailCount} prison(s) active(s)`; + } else { + if (f2bDot) f2bDot.className = 'w-2 h-2 rounded-full bg-rose-500'; + if (f2bStatus) { + f2bStatus.textContent = 'Arrêté'; + f2bStatus.className = 'text-sm font-extrabold text-rose-400 mt-1'; + } + if (f2bDetail) f2bDetail.textContent = 'Service non démarré'; + } + + const bannedCountEl = document.getElementById('dash-banned-count'); + if (bannedCountEl) bannedCountEl.textContent = sec.total_banned || 0; + + let totalFailedAll = 0; + let totalBannedAll = 0; + Object.values(sec.jails || {}).forEach(j => { + totalFailedAll += (j.total_failed || 0); + totalBannedAll += (j.total_banned || 0); + }); + + const blockedBadge = document.getElementById('dash-total-blocked-badge'); + if (blockedBadge) { + blockedBadge.textContent = `${totalFailedAll} attaque(s) contrée(s)`; + if (totalFailedAll > 0) { + blockedBadge.className = 'px-2.5 py-1 rounded-full text-[11px] font-bold bg-amber-500/15 text-amber-300 border border-amber-500/30 font-mono'; + } else { + blockedBadge.className = 'px-2.5 py-1 rounded-full text-[11px] font-bold bg-emerald-500/15 text-emerald-300 border border-emerald-500/30 font-mono'; + } + } + + const latestBannedEl = document.getElementById('dash-latest-banned'); + if (latestBannedEl) { + if (sec.banned_ips && sec.banned_ips.length > 0) { + latestBannedEl.textContent = `Bannie : ${sec.banned_ips[0].ip}`; + latestBannedEl.className = 'text-[10px] text-rose-400 mt-1 truncate font-mono font-bold'; + } else { + latestBannedEl.textContent = 'Aucun ban actif'; + latestBannedEl.className = 'text-[10px] text-emerald-400 mt-1 truncate'; + } + } + + const clientIpEl = document.getElementById('dash-client-ip'); + if (clientIpEl && m.network?.ip) { + clientIpEl.textContent = `IP locale PBX : ${m.network.ip}`; + } + + // Graphique Répartition Jails + const astJail = sec.jails?.asterisk || {}; + const webJail = sec.jails?.['nexus-web'] || {}; + const sshJail = sec.jails?.sshd || {}; + + const astFailed = astJail.total_failed || 0; + const webFailed = webJail.total_failed || 0; + const sshFailed = sshJail.total_failed || 0; + const totalThreats = astFailed + webFailed + sshFailed; + + const astPct = totalThreats > 0 ? Math.round((astFailed / totalThreats) * 100) : 0; + const webPct = totalThreats > 0 ? Math.round((webFailed / totalThreats) * 100) : 0; + const sshPct = totalThreats > 0 ? Math.round((sshFailed / totalThreats) * 100) : 0; + + const astStats = document.getElementById('dash-jail-ast-stats'); + const astBar = document.getElementById('dash-jail-ast-bar'); + if (astStats) astStats.textContent = `${astFailed} contrée(s) (${astPct}%) • ${astJail.currently_banned || 0} ban actif`; + if (astBar) astBar.style.width = `${astPct}%`; + + const webStats = document.getElementById('dash-jail-web-stats'); + const webBar = document.getElementById('dash-jail-web-bar'); + if (webStats) webStats.textContent = `${webFailed} contrée(s) (${webPct}%) • ${webJail.currently_banned || 0} ban actif`; + if (webBar) webBar.style.width = `${webPct}%`; + + const sshStats = document.getElementById('dash-jail-ssh-stats'); + const sshBar = document.getElementById('dash-jail-ssh-bar'); + if (sshStats) sshStats.textContent = `${sshFailed} contrée(s) (${sshPct}%) • ${sshJail.currently_banned || 0} ban actif`; + if (sshBar) sshBar.style.width = `${sshPct}%`; + } + + // 9. Rendu de la liste des Services Système + if (m.services && Array.isArray(m.services)) { + renderDashboardServicesList(m.services); + } +} + +function renderDashboardServicesList(services) { + const container = document.getElementById('dash-services-list'); + if (!container) return; + + let allOk = true; + const html = services.map(s => { + if (!s.active && s.id !== 'firewall') allOk = false; + const dotClass = s.active + ? 'w-2 h-2 rounded-full bg-emerald-400 shadow-sm shadow-emerald-400/50' + : 'w-2 h-2 rounded-full bg-rose-500'; + const badgeClass = s.active + ? 'px-2 py-0.5 rounded text-[10px] font-bold bg-emerald-500/15 text-emerald-300 border border-emerald-500/30' + : 'px-2 py-0.5 rounded text-[10px] font-bold bg-rose-500/15 text-rose-300 border border-rose-500/30'; + + return ` +
+
+ +
+
+ ${escapeHtml(s.name)} + (${escapeHtml(s.port)}) +
+
${escapeHtml(s.desc)}
+
+
+
+ ${escapeHtml(s.status_text)} +
${escapeHtml(s.uptime)}
+
+
+ `; + }).join(''); + + container.innerHTML = html; + + const allBadge = document.getElementById('dash-services-all-badge'); + if (allBadge) { + if (allOk) { + allBadge.textContent = 'TOUS OPÉRATIONNELS'; + allBadge.className = 'px-2 py-0.5 rounded-full text-[10px] font-bold bg-emerald-500/20 text-emerald-300 border border-emerald-500/30'; + } else { + allBadge.textContent = 'ATTENTION DÉMON'; + allBadge.className = 'px-2 py-0.5 rounded-full text-[10px] font-bold bg-amber-500/20 text-amber-300 border border-amber-500/30'; + } + } +} + +function updateDonut(circleId, labelId, pct) { + const circle = document.getElementById(circleId); + const label = document.getElementById(labelId); + const clamped = Math.max(0, Math.min(100, Math.round(pct))); + if (circle) { + circle.setAttribute('stroke-dasharray', `${clamped}, 100`); + } + if (label) { + label.textContent = `${clamped}%`; + } +} + +function drawDualAreaChart(svgId, seriesA, seriesB, maxVal, colorA, colorB, unitLabel) { + const svg = document.getElementById(svgId); + if (!svg) return; + + const width = 500; + const height = 180; + const padTop = 14; + const padBottom = 18; + const plotH = height - padTop - padBottom; + const safeMax = maxVal > 0 ? maxVal : 100; + + const toPoints = (arr) => arr.map((val, idx) => { + const x = (idx / (arr.length - 1)) * width; + const ratio = Math.max(0, Math.min(1, val / safeMax)); + const y = padTop + plotH - (ratio * plotH); + return `${x.toFixed(1)},${y.toFixed(1)}`; + }); + + const ptsA = toPoints(seriesA); + const ptsB = toPoints(seriesB); + + const areaA = `0,${padTop + plotH} ` + ptsA.join(' ') + ` ${width},${padTop + plotH}`; + const areaB = `0,${padTop + plotH} ` + ptsB.join(' ') + ` ${width},${padTop + plotH}`; + + const gradIdA = `${svgId}-grad-a`; + const gradIdB = `${svgId}-grad-b`; + + svg.innerHTML = ` + + + + + + + + + + + + + + + + + ${Math.round(safeMax)} ${unitLabel} + ${Math.round(safeMax / 2)} ${unitLabel} + + + + + + + + + `; +} + +function getContactMap() { + const map = {}; + const contacts = appState.metrics?.asterisk?.contacts || []; + contacts.forEach(c => { + map[String(c.extension)] = c; + }); + return map; +} + +function extractCleanIp(uri) { + if (!uri) return ''; + const m = uri.match(/@([0-9a-zA-Z\.\-]+(?::\d+)?)/); + if (m) return m[1]; + const m2 = uri.match(/^sip:([0-9a-zA-Z\.\-]+(?::\d+)?)/); + if (m2) return m2[1]; + return uri.split(';')[0].replace(/^sip:/, ''); +} + +function renderDashboardPhonesTable() { + const tbody = document.getElementById('dashboard-phones-tbody'); + if (!tbody) return; + + const exts = appState.extensions || []; + const contactMap = getContactMap(); + + if (exts.length === 0) { + tbody.innerHTML = `Aucun poste SIP configuré.`; + return; + } + + tbody.innerHTML = exts.map(ext => { + const c = contactMap[String(ext.extension)]; + const isTls = ext.transport === 'tls'; + const st = c ? String(c.status).toLowerCase() : ''; + const isUnavail = c && st.includes('unavail'); + const isOnline = c && !isUnavail && (st.startsWith('avail') || st.includes('nonqual')); + + // Chiffrement Voix (SRTP) configuré pour l'extension + const encMode = Number(ext.encryption ?? (isTls ? 1 : 0)); + let srtpBadge = ''; + if (encMode === 2) { + srtpBadge = `🛡️ SRTP Forcé`; + } else if (encMode === 1) { + srtpBadge = `🛡️ SRTP Négociable`; + } else { + srtpBadge = `🔈 RTP Clair`; + } + + // Détection dynamique du mode réel négocié en direct par le téléphone + let modeBadge = ''; + if (c) { + const actTr = (c.active_transport || 'udp').toLowerCase(); + let trPill = ''; + let profileNote = ''; + + if (actTr === 'tls') { + trPill = ` + + 🔒 TLS Actif (Chiffré) + + `; + profileNote = `${isTls ? 'TLS Strict' : 'UDP Interne'}`; + } else if (actTr === 'wss') { + trPill = ` + + 🔒 WSS WebRTC + + `; + profileNote = `WebRTC`; + } else if (actTr === 'tcp') { + trPill = ` + + 🔓 TCP Actif (Clair) + + `; + profileNote = `${isTls ? 'TLS Strict' : 'UDP Interne'}`; + } else { + // UDP + trPill = ` + + 🔓 UDP Actif (Clair) + + `; + profileNote = isTls ? '⚠️ Exige TLS' : 'UDP Interne'; + } + + modeBadge = ` +
+
${trPill}
+
+ ${srtpBadge} + Profil: ${profileNote} +
+
+ `; + } else { + modeBadge = ` +
+
+ + Inactif + +
+
+ ${srtpBadge} + Profil: ${isTls ? 'TLS Strict' : 'UDP Interne'} +
+
+ `; + } + + // Statut SIP + let statusBadge = ''; + if (isOnline) { + statusBadge = `En ligne (${escapeHtml(c.status)})`; + } else if (isUnavail) { + statusBadge = `Injoignable (${escapeHtml(c.status)})`; + } else { + statusBadge = `Hors-ligne`; + } + + // Contact / IP & Modèle Téléphone (User-Agent) + let contactHtml = ''; + if (c) { + const cleanIp = extractCleanIp(c.uri); + const ua = c.user_agent ? escapeHtml(c.user_agent) : ''; + contactHtml = ` +
+
${escapeHtml(cleanIp)}
+ ${ua ? `
📱 ${ua}
` : ''} +
+ `; + } else { + contactHtml = `Non enregistré`; + } + + let rttText = '—'; + let rttColor = 'text-slate-500'; + if (isOnline && c && c.rtt_ms !== null && !isNaN(c.rtt_ms)) { + rttText = `${c.rtt_ms} ms`; + rttColor = c.rtt_ms < 30 ? 'text-emerald-300 font-bold' : (c.rtt_ms < 100 ? 'text-cyan-300' : 'text-amber-300'); + } else if (isUnavail) { + rttText = 'Inaccessible'; + rttColor = 'text-rose-400/90 font-semibold italic'; + } + + return ` + + ${escapeHtml(ext.extension)} + ${escapeHtml(ext.name)} + ${modeBadge} + ${statusBadge} + ${contactHtml} + ${rttText} + + `; + }).join(''); +} + +// --- 1. Rendu & CRUD Extensions --- +function renderExtensionsTable() { + const tbody = document.getElementById('extensions-tbody'); + if (!tbody) return; + const rows = appState.extensions || []; + const contactMap = getContactMap(); + const groups = appState.groups || []; + + if (rows.length === 0) { + tbody.innerHTML = `Aucun poste configuré. Cliquez sur « + Nouveau Poste SIP ».`; + return; + } + + tbody.innerHTML = rows.map(ext => { + const c = contactMap[String(ext.extension)]; + const isTls = ext.transport === 'tls'; + const encMode = Number(ext.encryption ?? (isTls ? 1 : 0)); + let encLabel = ''; + if (encMode === 2) { + encLabel = 'SRTP Forcé'; + } else if (encMode === 1) { + encLabel = 'SRTP Négociable'; + } else { + encLabel = 'RTP Clair'; + } + const badge = isTls + ? `🔒 TLS (${encLabel})` + : (encMode > 0 + ? `🛡️ UDP (${encLabel})` + : `🏢 UDP (RTP Clair)`); + + const actTr = c ? (c.active_transport || 'udp').toUpperCase() : ''; + const stLower = c ? String(c.status).toLowerCase() : ''; + const isCUnavail = c && stLower.includes('unavail'); + const isCOnline = c && !isCUnavail && (stLower.startsWith('avail') || stLower.includes('nonqual')); + const regBadge = c + ? (isCOnline + ? `Connecté (${actTr}${c.rtt_ms !== null && !isNaN(c.rtt_ms) ? ` • ${c.rtt_ms}ms` : ''})` + : `Injoignable (${actTr})`) + : `Non connecté`; + + // Trouve à quels groupes d'extensions (Sites/Droits) ce poste appartient (ex: Nice, Paris) + const extGroups = appState.extension_groups || []; + const myExtGroups = extGroups.filter(eg => { + const mList = Array.isArray(eg.members) ? eg.members.map(String) : []; + return mList.includes(String(ext.extension)); + }); + const groupBadges = myExtGroups.length > 0 + ? myExtGroups.map(eg => `${escapeHtml(eg.name)}`).join(' ') + : `Aucun`; + + const vmBadge = Number(ext.voicemail_enabled) === 1 + ? `📟 Boîte` + : ''; + + return ` + + ${escapeHtml(ext.extension)} + +
+ ${escapeHtml(ext.name)} + ${vmBadge} +
+ +
${groupBadges}
+ ${regBadge} + ${badge} + ${escapeHtml(ext.codecs)} + + ${escapeHtml(ext.secret)} + + + + + + + `; + }).join(''); +} + +function switchExtModalTab(tab) { + const isGeneral = (tab === 'general'); + const btnGen = document.getElementById('tab-btn-ext-general'); + const btnVm = document.getElementById('tab-btn-ext-voicemail'); + const panGen = document.getElementById('tab-panel-ext-general'); + const panVm = document.getElementById('tab-panel-ext-voicemail'); + + if (btnGen) { + btnGen.className = isGeneral + ? 'px-3 py-1.5 rounded-lg text-xs font-bold transition bg-indigo-600 text-white' + : 'px-3 py-1.5 rounded-lg text-xs font-semibold transition text-slate-300 hover:text-white hover:bg-anthracite-700'; + } + if (btnVm) { + btnVm.className = !isGeneral + ? 'px-3 py-1.5 rounded-lg text-xs font-bold transition bg-indigo-600 text-white flex items-center gap-1.5' + : 'px-3 py-1.5 rounded-lg text-xs font-semibold transition text-slate-300 hover:text-white hover:bg-anthracite-700 flex items-center gap-1.5'; + } + if (panGen) panGen.classList.toggle('hidden', !isGeneral); + if (panVm) panVm.classList.toggle('hidden', isGeneral); +} + +function syncVoicemailToggle(enabled) { + const details = document.getElementById('voicemail-details-container'); + if (details) { + details.style.opacity = enabled ? '1' : '0.4'; + details.style.pointerEvents = enabled ? 'auto' : 'none'; + } + const badge = document.getElementById('badge-ext-vm-status'); + if (badge) { + badge.className = `w-2 h-2 rounded-full ${enabled ? 'bg-amber-400' : 'bg-slate-500'}`; + } +} + +function syncPinMode(mode) { + const isManual = (mode === 'manual'); + const pinBox = document.getElementById('pin-input-box'); + const forceInput = document.getElementById('ext-force-pin-change'); + if (pinBox) { + pinBox.style.display = isManual ? 'block' : 'none'; + } + if (forceInput) { + forceInput.value = isManual ? '0' : '1'; + } +} + +function openExtensionModal(ext = null) { + const form = document.getElementById('form-extension'); + form.reset(); + document.getElementById('modal-extension-title').textContent = ext ? `Modifier le poste ${ext.extension}` : 'Nouveau Poste SIP'; + + form.elements['id'].value = ext ? ext.id : ''; + form.elements['extension'].value = ext ? ext.extension : ''; + form.elements['name'].value = ext ? ext.name : ''; + form.elements['secret'].value = ext ? ext.secret : generateSecretString(); + form.elements['codecs'].value = ext ? ext.codecs : 'alaw,ulaw,g722,opus'; + form.elements['max_contacts'].value = ext ? ext.max_contacts : 2; + + const transport = ext ? ext.transport : 'udp'; + form.querySelectorAll('input[name="transport"]').forEach(r => { + r.checked = (r.value === transport); + }); + syncEncryptionToggle(transport, ext ? (ext.encryption ?? (transport === 'tls' ? 1 : 0)) : (transport === 'tls' ? 1 : 0)); + + // Réinitialisation de l'onglet actif vers "Général" + switchExtModalTab('general'); + + // Initialisation des champs Messagerie Vocale + const vmEnabled = ext ? (Number(ext.voicemail_enabled) === 1) : false; + const vmCheckbox = document.getElementById('ext-voicemail-enabled'); + if (vmCheckbox) vmCheckbox.checked = vmEnabled; + + const isFirstCall = ext ? (Number(ext.force_pin_change) === 1) : false; + form.querySelectorAll('input[name="pin_mode"]').forEach(r => { + r.checked = isFirstCall ? (r.value === 'first_call') : (r.value === 'manual'); + }); + syncPinMode(isFirstCall ? 'first_call' : 'manual'); + + if (form.elements['voicemail_pin']) { + form.elements['voicemail_pin'].value = ext ? (ext.voicemail_pin || '') : ''; + } + if (form.elements['ring_time']) { + form.elements['ring_time'].value = ext ? (ext.ring_time || 20) : 20; + } + + const skipPinCb = document.getElementById('ext-skip-pin'); + if (skipPinCb) { + skipPinCb.checked = ext ? (Number(ext.voicemail_skip_pin) === 1) : true; + } + + if (form.elements['voicemail_email']) { + form.elements['voicemail_email'].value = ext ? (ext.voicemail_email || '') : ''; + } + + const vmAttachCb = document.getElementById('ext-vm-attach'); + if (vmAttachCb) { + vmAttachCb.checked = ext ? (Number(ext.voicemail_attach) === 1) : true; + } + + const vmDeleteCb = document.getElementById('ext-vm-delete'); + if (vmDeleteCb) { + vmDeleteCb.checked = ext ? (Number(ext.voicemail_delete) === 1) : false; + } + + syncVoicemailToggle(vmEnabled); + + // Rendu des cases d'appartenance aux Groupes d'Extensions (Sites: Nice, Paris...) + const grpBox = document.getElementById('extension-groups-checkboxes'); + if (grpBox) { + const extGroups = appState.extension_groups || []; + if (extGroups.length === 0) { + grpBox.innerHTML = `Aucun groupe d'extensions créé. Vous pouvez en créer dans « Groupes » → « Groupes d'extensions ».`; + } else { + const currentExt = ext ? String(ext.extension) : ''; + grpBox.innerHTML = extGroups.map(eg => { + const isMember = currentExt && Array.isArray(eg.members) && eg.members.map(String).includes(currentExt); + return ` + + `; + }).join(''); + } + } + + openModal('modal-extension'); +} + +function editExtension(id) { + const ext = (appState.extensions || []).find(e => Number(e.id) === Number(id)); + if (ext) openExtensionModal(ext); +} + +async function deleteExtension(id, optNum = null) { + const ext = (appState.extensions || []).find(e => Number(e.id) === Number(id)); + const num = optNum || (ext ? ext.extension : 'ce poste'); + if (!confirm(`Supprimer définitivement le poste SIP ${num} ?`)) return; + try { + const res = await apiCall('delete_extension', { id: Number(id) }); + showToast(res.message, 'success'); + await loadState(); + } catch (err) { + showToast(err.message, 'error'); + } +} + +function syncEncryptionToggle(transport, currentVal = null) { + const sel = document.getElementById('ext-encryption'); + if (!sel) return; + if (currentVal !== null) { + sel.value = String(currentVal); + } else if (transport === 'tls' && sel.value === '0') { + sel.value = '1'; + } +} + +function generateSecretString() { + const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@#$%'; + let out = ''; + for (let i = 0; i < 14; i++) { + out += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return out; +} + +function generateRandomSecret() { + const form = document.getElementById('form-extension'); + form.elements['secret'].value = generateSecretString(); +} + +// --- 2A. Rendu & CRUD Groupes de Sonnerie (Ring Groups) --- +function renderGroupsTable() { + const tbody = document.getElementById('groups-tbody'); + if (!tbody) return; + const rows = appState.groups || []; + + if (rows.length === 0) { + tbody.innerHTML = `Aucun groupe de sonnerie configuré. Cliquez sur « + Nouveau Groupe de Sonnerie ».`; + return; + } + + tbody.innerHTML = rows.map(grp => { + const members = Array.isArray(grp.members) ? grp.members : []; + const badges = members.length + ? members.map(m => `${escapeHtml(m)}`).join(' ') + : 'Aucun membre'; + + return ` + + ${escapeHtml(grp.extension)} + ${escapeHtml(grp.name)} + ${escapeHtml(grp.strategy)} +
${badges}
+ ${Number(grp.ring_time)}s → ${escapeHtml(grp.failover_dest)} + + + + + + `; + }).join(''); +} + +let activeGroupSelectedExts = new Set(); + +function openGroupModal(grp = null) { + const form = document.getElementById('form-group'); + form.reset(); + document.getElementById('modal-group-title').textContent = grp ? `Modifier le groupe de sonnerie ${grp.name} (${grp.extension})` : "Nouveau Groupe de Sonnerie"; + + form.elements['id'].value = grp ? grp.id : ''; + form.elements['extension'].value = grp ? grp.extension : ''; + form.elements['name'].value = grp ? grp.name : ''; + form.elements['strategy'].value = grp ? grp.strategy : 'ringall'; + form.elements['ring_time'].value = grp ? grp.ring_time : 20; + + // Failover destinations + const failoverSel = document.getElementById('group-failover-select'); + if (failoverSel) { + failoverSel.innerHTML = buildDestinationOptionsHtml(grp ? grp.failover_dest : 'hangup'); + } + + activeGroupSelectedExts = new Set(grp && Array.isArray(grp.members) ? grp.members.map(String) : []); + renderGroupInteractiveBoxes(); + + openModal('modal-group'); +} + +function renderGroupInteractiveBoxes() { + const allowedBox = document.getElementById('group-members-allowed-box'); + const blockedBox = document.getElementById('group-members-blocked-box'); + const hiddenBox = document.getElementById('group-members-checkboxes'); + if (!allowedBox || !blockedBox) return; + + const allExts = appState.extensions || []; + let allowedHtml = ''; + let blockedHtml = ''; + let hiddenHtml = ''; + let allowedCount = 0; + let blockedCount = 0; + + allExts.forEach(ext => { + const extStr = String(ext.extension); + const isSel = activeGroupSelectedExts.has(extStr); + if (isSel) { + allowedCount++; + allowedHtml += ` + + `; + hiddenHtml += ``; + } else { + blockedCount++; + blockedHtml += ` + + `; + } + }); + + allowedBox.innerHTML = allowedHtml || `
Aucun poste qui sonne. Cliquez sur un poste à droite pour l'ajouter.
`; + blockedBox.innerHTML = blockedHtml || `
Tous les postes sonnent dans ce groupe !
`; + if (hiddenBox) hiddenBox.innerHTML = hiddenHtml; + + const countA = document.getElementById('group-selected-count'); + const countB = document.getElementById('group-unselected-count'); + if (countA) countA.textContent = String(allowedCount); + if (countB) countB.textContent = String(blockedCount); +} + +function toggleGroupMemberExt(extStr) { + if (activeGroupSelectedExts.has(extStr)) { + activeGroupSelectedExts.delete(extStr); + } else { + activeGroupSelectedExts.add(extStr); + } + renderGroupInteractiveBoxes(); +} + +function groupTransferAllExtensions(includeAll) { + const allExts = appState.extensions || []; + if (includeAll) { + allExts.forEach(e => activeGroupSelectedExts.add(String(e.extension))); + } else { + activeGroupSelectedExts.clear(); + } + renderGroupInteractiveBoxes(); +} + +function editGroup(id) { + const grp = (appState.groups || []).find(g => Number(g.id) === Number(id)); + if (grp) openGroupModal(grp); +} + +async function deleteGroup(id, optNum = null) { + const grp = (appState.groups || []).find(g => Number(g.id) === Number(id)); + const num = optNum || (grp ? grp.extension : 'ce groupe'); + if (!confirm(`Supprimer le groupe de sonnerie ${num} ?`)) return; + try { + const res = await apiCall('delete_group', { id: Number(id) }); + showToast(res.message, 'success'); + await loadState(); + } catch (err) { + showToast(err.message, 'error'); + } +} + +// --- 2B. Rendu & CRUD Groupes d'Extensions (Sites & Droits Routes Sortantes) --- +function renderExtensionGroupsTable() { + const tbody = document.getElementById('extgroups-tbody'); + if (!tbody) return; + const rows = appState.extension_groups || []; + const obRoutes = appState.outbound_routes || []; + + if (rows.length === 0) { + tbody.innerHTML = `Aucun groupe d'extensions configuré. Cliquez sur « + Nouveau Groupe d'Extensions ».`; + return; + } + + tbody.innerHTML = rows.map(eg => { + const members = Array.isArray(eg.members) ? eg.members : []; + const badges = members.length + ? members.map(m => `${escapeHtml(m)}`).join(' ') + : 'Aucune extension'; + + // Routes sortantes où ce groupe d'extensions a accès + const egIdStr = String(eg.id); + const egNameStr = String(eg.name); + const accessibleRoutes = obRoutes.filter(r => { + if (r.permission_mode === 'all') return true; + const ag = Array.isArray(r.allowed_groups) ? r.allowed_groups.map(String) : []; + return ag.includes(egIdStr) || ag.includes(egNameStr); + }); + const routeBadges = accessibleRoutes.length > 0 + ? accessibleRoutes.map(r => `${escapeHtml(r.name)}`).join(' ') + : `Aucune (Verrouillé)`; + + return ` + + +
+ + ${escapeHtml(eg.name)} +
+ + ${escapeHtml(eg.description || '—')} +
${badges}
+
${routeBadges}
+ + + + + + `; + }).join(''); +} + +let activeExtGroupSelectedExts = new Set(); + +function openExtGroupModal(eg = null) { + const form = document.getElementById('form-extgroup'); + form.reset(); + document.getElementById('modal-extgroup-title').textContent = eg ? `Modifier le groupe d'extensions ${eg.name}` : "Nouveau Groupe d'Extensions"; + + form.elements['id'].value = eg ? eg.id : ''; + form.elements['name'].value = eg ? eg.name : ''; + form.elements['description'].value = eg ? eg.description : ''; + + activeExtGroupSelectedExts = new Set(eg && Array.isArray(eg.members) ? eg.members.map(String) : []); + renderExtGroupInteractiveBoxes(); + + openModal('modal-extgroup'); +} + +function renderExtGroupInteractiveBoxes() { + const allowedBox = document.getElementById('extgroup-members-allowed-box'); + const blockedBox = document.getElementById('extgroup-members-blocked-box'); + if (!allowedBox || !blockedBox) return; + + const allExts = appState.extensions || []; + let allowedHtml = ''; + let blockedHtml = ''; + let allowedCount = 0; + let blockedCount = 0; + + allExts.forEach(ext => { + const extStr = String(ext.extension); + const isSel = activeExtGroupSelectedExts.has(extStr); + if (isSel) { + allowedCount++; + allowedHtml += ` + + `; + } else { + blockedCount++; + blockedHtml += ` + + `; + } + }); + + allowedBox.innerHTML = allowedHtml || `
Aucune extension rattachée. Cliquez à droite pour en ajouter.
`; + blockedBox.innerHTML = blockedHtml || `
Toutes les extensions sont dans ce groupe !
`; + + const countA = document.getElementById('extgroup-selected-count'); + const countB = document.getElementById('extgroup-unselected-count'); + if (countA) countA.textContent = String(allowedCount); + if (countB) countB.textContent = String(blockedCount); +} + +function toggleExtGroupMember(extStr) { + if (activeExtGroupSelectedExts.has(extStr)) { + activeExtGroupSelectedExts.delete(extStr); + } else { + activeExtGroupSelectedExts.add(extStr); + } + renderExtGroupInteractiveBoxes(); +} + +function extGroupTransferAll(includeAll) { + const allExts = appState.extensions || []; + if (includeAll) { + allExts.forEach(e => activeExtGroupSelectedExts.add(String(e.extension))); + } else { + activeExtGroupSelectedExts.clear(); + } + renderExtGroupInteractiveBoxes(); +} + +function editExtGroup(id) { + const eg = (appState.extension_groups || []).find(g => Number(g.id) === Number(id)); + if (eg) openExtGroupModal(eg); +} + +async function deleteExtGroup(id, optName = null) { + const eg = (appState.extension_groups || []).find(g => Number(g.id) === Number(id)); + const name = optName || (eg ? eg.name : 'ce groupe'); + if (!confirm(`Supprimer définitivement le groupe d'extensions « ${name} » ?`)) return; + try { + const res = await apiCall('delete_extension_group', { id: Number(id) }); + showToast(res.message, 'success'); + await loadState(); + } catch (err) { + showToast(err.message, 'error'); + } +} + +// --- 3. Rendu & CRUD Trunks SIP (Configuration PJSIP Avancée) --- +function slugifyTrunkName(text) { + return String(text || '') + .toLowerCase() + .trim() + .replace(/^ivr-/, '') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') || 'default'; +} + +function renderTrunksTable() { + const tbody = document.getElementById('trunks-tbody'); + if (!tbody) return; + const rows = appState.trunks || []; + const regs = appState.metrics?.asterisk?.registrations || {}; + + if (rows.length === 0) { + tbody.innerHTML = `Aucun Trunk SIP opérateur configuré. Cliquez sur « + Nouveau Trunk SIP ».`; + return; + } + + tbody.innerHTML = rows.map(t => { + const slug = `trunk-${slugifyTrunkName(t.name)}`; + const regInfo = regs[slug]; + const isIpAuth = t.auth_mode === 'ip'; + const wantsReg = !isIpAuth && Number(t.register) === 1; + + let statusBadge = ''; + if (Number(t.enabled ?? 1) === 0) { + statusBadge = `Désactivé`; + } else if (isIpAuth || !wantsReg) { + statusBadge = `Peering / Sans Reg.`; + } else if (regInfo && /Registered/i.test(regInfo.status)) { + statusBadge = `Registered`; + } else if (regInfo) { + statusBadge = `${escapeHtml(regInfo.status)}`; + } else { + statusBadge = `En attente Apply`; + } + + const routesArr = Array.isArray(t.did_routes) ? t.did_routes : Object.entries(t.did_routes || {}).map(([did, destination]) => ({ cid: 'Any', did, description: '', destination })); + const didCount = routesArr.length; + const blockCount = routesArr.filter(r => String(r.destination || '').startsWith('terminate-')).length; + const tcCount = routesArr.filter(r => Number(r.time_condition_enabled) === 1).length; + const paiBadges = [ + Number(t.send_pai ?? 1) === 1 ? 'PAI' : null, + Number(t.send_rpid ?? 0) === 1 ? 'RPID' : null, + t.media_encryption && t.media_encryption !== 'no' ? t.media_encryption.toUpperCase() : null + ].filter(Boolean).map(b => `${b}`).join(' '); + + const mohSound = (appState.sounds || []).find(s => Number(s.id) === Number(t.moh_sound_id)); + const mohBadge = mohSound + ? `🎵 ${escapeHtml(mohSound.name)}` + : `MoH : Globale`; + + return ` + + +
${escapeHtml(t.name)}
+
[${escapeHtml(slug)}]
+ + ${statusBadge} + +
${escapeHtml(t.host)}:${Number(t.port)}
+ ${t.outbound_proxy ? `
Proxy: ${escapeHtml(t.outbound_proxy)}
` : ''} + + +
${escapeHtml(isIpAuth ? 'Auth par IP' : (t.username || '—'))}
+
${escapeHtml(t.from_domain || t.host)} ${paiBadges}
+ + + ${escapeHtml(t.transport)} + ${escapeHtml(t.codecs)} + + +
→ ${escapeHtml(t.inbound_dest)}
+
+ ${didCount > 0 ? `${didCount} règle(s) entrante(s)` : 'Tous numéros (Any)'} + ${blockCount > 0 ? `${blockCount} blocage(s) CID` : ''} + ${tcCount > 0 ? `⏱️ ${tcCount} horaire(s)` : ''} +
+
${mohBadge}
+ + + ${wantsReg ? `` : ''} + + + + + + `; + }).join(''); +} + +async function forceRegisterTrunk(regName = '*all') { + try { + const res = await apiCall('force_register_trunk', { reg_name: regName }); + showToast(res.message, 'success'); + await loadState(true); + } catch (err) { + showToast(err.message, 'error'); + } +} + +function switchTrunkModalTab(subTab) { + document.querySelectorAll('.trunk-subpanel').forEach(p => p.classList.add('hidden')); + const target = document.getElementById(`trunk-panel-${subTab}`); + if (target) target.classList.remove('hidden'); + + document.querySelectorAll('.trunk-tab-btn').forEach(btn => { + if (btn.dataset.trunkTab === subTab) { + btn.className = 'trunk-tab-btn px-3.5 py-2 text-xs font-bold rounded-t-xl bg-indigo-600 text-white transition flex items-center gap-1.5'; + } else { + btn.className = 'trunk-tab-btn px-3.5 py-2 text-xs font-semibold rounded-t-xl bg-anthracite-950 text-slate-300 hover:text-white transition flex items-center gap-1.5'; + } + }); +} + +function toggleTrunkAuthFields(mode) { + const container = document.getElementById('trunk-auth-fields'); + const form = document.getElementById('form-trunk'); + if (!container || !form) return; + if (mode === 'ip') { + container.classList.add('opacity-40', 'pointer-events-none'); + form.elements['register'].checked = false; + } else { + container.classList.remove('opacity-40', 'pointer-events-none'); + } +} + +function normalizeDestinationValue(rawVal) { + const exts = appState.extensions || []; + const grps = appState.groups || []; + let normVal = String(rawVal || '').trim(); + if (!normVal) return 'ext-101'; + if (normVal === 'busy') return 'terminate-busy'; + if (normVal === 'congestion') return 'terminate-congestion'; + if (normVal === 'zapateller') return 'terminate-zapateller'; + if (normVal === 'hangup') return 'terminate-hangup'; + if (/^\d+$/.test(normVal)) { + if (exts.some(e => String(e.extension) === normVal)) return `ext-${normVal}`; + if (grps.some(g => String(g.extension) === normVal)) return `grp-${normVal}`; + } + return normVal; +} + +function buildDestinationOptionsHtml(selectedValue = 'ext-101') { + const exts = appState.extensions || []; + const grps = appState.groups || []; + const ivrs = appState.ivrs || []; + const sounds = appState.sounds || []; + const normVal = normalizeDestinationValue(selectedValue); + + let html = ''; + if (exts.length > 0) { + html += ``; + exts.forEach(e => { + const val = `ext-${e.extension}`; + html += ``; + }); + html += ``; + } + + if (grps.length > 0) { + html += ``; + grps.forEach(g => { + const val = `grp-${g.extension}`; + html += ``; + }); + html += ``; + } + + if (ivrs.length > 0) { + html += ``; + ivrs.forEach(i => { + const val = i.name; + html += ``; + }); + html += ``; + } + + // Piège à Spammeurs / Musique d'attente en boucle continue (MoH) + const mohSounds = sounds.filter(s => s.usage_type === 'moh'); + html += ``; + html += ``; + mohSounds.forEach(s => { + const val = `moh-${s.id}`; + html += ``; + }); + html += ``; + + const announcements = sounds.filter(s => s.usage_type !== 'moh'); + if (announcements.length > 0) { + html += ``; + announcements.forEach(s => { + const val = `sound-${s.id}`; + html += ``; + }); + html += ``; + + html += ``; + announcements.forEach(s => { + const val = `sound-loop-${s.id}`; + html += ``; + }); + html += ``; + } + + html += ` + + + + + + + + `; + + return html; +} + +function updateInboundRoutesBadge() { + const count = document.querySelectorAll('#trunk-did-container .trunk-did-row').length; + const badge = document.getElementById('trunk-inbound-rules-badge'); + if (badge) badge.textContent = String(count); +} + +function styleDestinationSelect(selectEl) { + if (!selectEl) return; + const val = selectEl.value || ''; + if (val.startsWith('terminate-')) { + selectEl.className = 'trunk-did-dest w-full px-2.5 py-1.5 rounded-lg bg-amber-500/10 border border-amber-500/40 text-xs text-amber-300 font-semibold'; + } else if (val.startsWith('moh-') || val.startsWith('sound-loop-')) { + selectEl.className = 'trunk-did-dest w-full px-2.5 py-1.5 rounded-lg bg-fuchsia-500/15 border border-fuchsia-500/40 text-xs text-fuchsia-300 font-semibold'; + } else if (val.startsWith('grp-')) { + selectEl.className = 'trunk-did-dest w-full px-2.5 py-1.5 rounded-lg bg-cyan-500/10 border border-cyan-500/40 text-xs text-cyan-300 font-semibold'; + } else { + selectEl.className = 'trunk-did-dest w-full px-2.5 py-1.5 rounded-lg bg-emerald-500/10 border border-emerald-500/40 text-xs text-emerald-300 font-semibold'; + } +} + +function addTrunkInboundRouteRow(cid = 'Any', did = 'Any', description = '', dest = 'ext-101', prepend = false, timeOpts = {}) { + const container = document.getElementById('trunk-did-container'); + if (!container) return; + + const tcEnabled = Boolean(timeOpts && timeOpts.time_condition_enabled); + const tcStart = (timeOpts && timeOpts.time_start) ? timeOpts.time_start : '08:30'; + const tcFinish = (timeOpts && timeOpts.time_finish) ? timeOpts.time_finish : '18:00'; + const tcWeekdays = (timeOpts && timeOpts.weekdays) ? timeOpts.weekdays : 'mon-fri'; + const tcClosed = (timeOpts && timeOpts.time_dest_closed) ? timeOpts.time_dest_closed : 'terminate-hangup'; + + const row = document.createElement('div'); + row.className = 'trunk-did-row border-b border-anthracite-700/60 transition hover:bg-anthracite-800/40'; + + row.innerHTML = ` +
+
+ + +
+
+ +
+
+
+ +
+
+ + + +
+
+ + + + `; + + const selectEl = row.querySelector('.trunk-did-dest'); + styleDestinationSelect(selectEl); + + const tcCheck = row.querySelector('.trunk-tc-enabled'); + const timeDrawer = row.querySelector('.time-drawer'); + const timeFields = row.querySelector('.time-fields-grid'); + const toggleBtn = row.querySelector('.btn-toggle-time'); + const badgeContainer = row.querySelector('.time-badge-container'); + const tcStartInput = row.querySelector('.trunk-tc-start'); + const tcFinishInput = row.querySelector('.trunk-tc-finish'); + const tcWeekdaysSelect = row.querySelector('.trunk-tc-weekdays'); + + function updateTimeUi() { + const isEnabled = tcCheck.checked; + timeFields.classList.toggle('opacity-50', !isEnabled); + timeFields.classList.toggle('pointer-events-none', !isEnabled); + + if (isEnabled) { + toggleBtn.className = 'btn-toggle-time px-2 py-1.5 rounded-lg text-xs font-semibold bg-amber-500/20 text-amber-300 border border-amber-500/40 transition shadow-sm'; + const daysLabel = tcWeekdaysSelect.value === 'mon-fri' ? 'Lun-Ven' : (tcWeekdaysSelect.value === 'mon-sat' ? 'Lun-Sam' : (tcWeekdaysSelect.value === '*' ? '7j/7' : 'Sam-Dim')); + badgeContainer.innerHTML = `⏱️ ${escapeHtml(tcStartInput.value)}-${escapeHtml(tcFinishInput.value)} (${escapeHtml(daysLabel)})`; + } else { + toggleBtn.className = 'btn-toggle-time px-2 py-1.5 rounded-lg text-xs font-semibold bg-anthracite-950 hover:bg-anthracite-900 text-slate-400 hover:text-white border border-anthracite-700 transition'; + badgeContainer.innerHTML = ''; + } + } + + tcCheck.addEventListener('change', updateTimeUi); + tcStartInput.addEventListener('change', updateTimeUi); + tcFinishInput.addEventListener('change', updateTimeUi); + tcWeekdaysSelect.addEventListener('change', updateTimeUi); + + toggleBtn.addEventListener('click', () => { + timeDrawer.classList.toggle('hidden'); + }); + + updateTimeUi(); + + row.querySelector('.btn-del-route').addEventListener('click', () => { + row.remove(); + updateInboundRoutesBadge(); + }); + + row.querySelector('.btn-dup-route').addEventListener('click', () => { + const curCid = row.querySelector('.trunk-did-cid').value; + const curDid = row.querySelector('.trunk-did-num').value; + const curDesc = row.querySelector('.trunk-did-desc').value + ' (Copie)'; + const curDest = row.querySelector('.trunk-did-dest').value; + const curTcOpts = { + time_condition_enabled: tcCheck.checked, + time_start: tcStartInput.value, + time_finish: tcFinishInput.value, + weekdays: tcWeekdaysSelect.value, + time_dest_closed: row.querySelector('.trunk-tc-closed-dest').value + }; + addTrunkInboundRouteRow(curCid, curDid, curDesc, curDest, false, curTcOpts); + }); + + if (prepend && container.firstChild) { + container.insertBefore(row, container.firstChild); + } else { + container.appendChild(row); + } + updateInboundRoutesBadge(); +} + +function addArcepSpamBlockPreset() { + const spamRanges = [ + { cid: '_0162XXXXXX', desc: 'Block 0162 Démarchage' }, + { cid: '_0163XXXXXX', desc: 'Block 0163 Démarchage' }, + { cid: '_0270XXXXXX', desc: 'Block 0270 Démarchage' }, + { cid: '_0271XXXXXX', desc: 'Block 0271 Démarchage' }, + { cid: '_0377XXXXXX', desc: 'Block 0377 Démarchage' }, + { cid: '_0378XXXXXX', desc: 'Block 0378 Démarchage' }, + { cid: '_0424XXXXXX', desc: 'Block 0424 Démarchage' }, + { cid: '_0425XXXXXX', desc: 'Block 0425 Démarchage' }, + { cid: '_0568XXXXXX', desc: 'Block 0568 Démarchage' }, + { cid: '_0569XXXXXX', desc: 'Block 0569 Démarchage' }, + { cid: '_0948XXXXXX', desc: 'Block 0948 Démarchage' }, + ]; + + const existingCids = new Set( + Array.from(document.querySelectorAll('#trunk-did-container .trunk-did-cid')).map(el => el.value.trim()) + ); + + let added = 0; + spamRanges.forEach(item => { + if (!existingCids.has(item.cid)) { + addTrunkInboundRouteRow(item.cid, 'Any', item.desc, 'terminate-busy', false); + added++; + } + }); + + if (added > 0) { + showToast(`${added} plages de démarchage ajoutées en mode « Terminate Call: Occupation » !`, 'success'); + } else { + showToast('Toutes les plages anti-démarchage sont déjà présentes dans la liste.', 'info'); + } +} + +function filterInboundRoutesRows(query) { + const q = String(query || '').toLowerCase().trim(); + document.querySelectorAll('#trunk-did-container .trunk-did-row').forEach(row => { + const cid = row.querySelector('.trunk-did-cid')?.value.toLowerCase() || ''; + const did = row.querySelector('.trunk-did-num')?.value.toLowerCase() || ''; + const desc = row.querySelector('.trunk-did-desc')?.value.toLowerCase() || ''; + const dest = row.querySelector('.trunk-did-dest')?.selectedOptions?.[0]?.textContent?.toLowerCase() || ''; + const match = !q || cid.includes(q) || did.includes(q) || desc.includes(q) || dest.includes(q); + row.classList.toggle('hidden', !match); + }); +} + +function syncTrunkHostDomain(hostVal) { + const form = document.getElementById('form-trunk'); + if (!form) return; + // Laisse from_domain vide pour qu'il prenne automatiquement la valeur exacte de host + form.elements['from_domain'].value = ''; +} + +function populateInboundDestDropdown(selectedValue = '101') { + const select = document.getElementById('trunk-inbound-select'); + const form = document.getElementById('form-trunk'); + if (!select || !form) return; + + select.innerHTML = buildDestinationOptionsHtml(selectedValue); + form.elements['inbound_dest'].value = select.value; +} + +function syncInboundDestSelect(val) { + const form = document.getElementById('form-trunk'); + if (form) { + form.elements['inbound_dest'].value = val; + } +} + +function toggleFreePbxImporter() { + const box = document.getElementById('freepbx-importer-box'); + if (box) box.classList.toggle('hidden'); +} + +function parseFreePbxConfig() { + const raw = (document.getElementById('freepbx-paste-input')?.value || '').trim(); + const form = document.getElementById('form-trunk'); + if (!raw || !form) return; + + // 1. Test format Register String : user:secret@host:port/contact + const regMatch = raw.match(/^(?:sip:)?([^:@\s]+):([^@\s]+)@([^:\/\s]+)(?::(\d+))?(?:\/(\S+))?$/i); + if (regMatch) { + const [, user, secret, host, port, contact] = regMatch; + if (!form.elements['name'].value) form.elements['name'].value = host.split('.')[0] + '-trunk'; + form.elements['host'].value = host; + form.elements['port'].value = port || 5060; + form.elements['username'].value = user; + form.elements['secret'].value = secret; + form.elements['from_domain'].value = ''; + if (contact) form.elements['contact_user'].value = contact; + form.elements['register'].checked = true; + toggleFreePbxImporter(); + showToast('Register String FreePBX convertie et appliquée !', 'success'); + return; + } + + // 2. Format PEER Details (clé=valeur) + const lines = raw.split(/\r?\n/); + let found = 0; + lines.forEach(line => { + const m = line.match(/^\s*([a-zA-Z0-9_]+)\s*=\s*(.+?)\s*$/); + if (!m) return; + const key = m[1].toLowerCase(); + const val = m[2].trim(); + found++; + + if (key === 'host') form.elements['host'].value = val; + else if (key === 'port') form.elements['port'].value = val; + else if (key === 'username' || key === 'defaultuser') form.elements['username'].value = val; + else if (key === 'authuser' || key === 'auth') form.elements['auth_username'].value = val; + else if (key === 'secret' || key === 'password') form.elements['secret'].value = val; + else if (key === 'fromuser') form.elements['from_user'].value = val; + else if (key === 'fromdomain') form.elements['from_domain'].value = val; + else if (key === 'context') { + if (val.includes('toheader')) form.elements['context'].value = 'from-pstn-toheader'; + else form.elements['context'].value = 'from-external'; + } + else if (key === 'outboundproxy') form.elements['outbound_proxy'].value = val; + }); + + if (found > 0) { + if (!form.elements['name'].value && form.elements['host'].value) { + form.elements['name'].value = form.elements['host'].value.split('.')[0] + '-trunk'; + } + toggleFreePbxImporter(); + showToast(`${found} paramètres FreePBX convertis vers PJSIP !`, 'success'); + } else { + showToast('Format non reconnu. Collez user:pass@host ou host=...', 'error'); + } +} + +function applyTrunkArchitecture(arch) { + const form = document.getElementById('form-trunk'); + if (!form) return; + + const architectures = { + register_auth: { + label: 'Trunk avec Enregistrement (REGISTER + Auth)', + port: 5060, + transport: 'udp', + auth_mode: 'userpass', + register: true, + line_support: true, + media_encryption: 'no', + nat_symmetric: true, + qualify_frequency: 60 + }, + ip_peering: { + label: 'Peering IP / SBC / Passerelle (Sans REGISTER)', + port: 5060, + transport: 'udp', + auth_mode: 'ip', + register: false, + line_support: false, + media_encryption: 'no', + nat_symmetric: true, + qualify_frequency: 30 + }, + tls_srtp: { + label: 'Trunk Sécurisé (TLS 5061 + SRTP SDES)', + port: 5061, + transport: 'tls', + auth_mode: 'userpass', + register: true, + line_support: true, + media_encryption: 'sdes', + nat_symmetric: true, + qualify_frequency: 60 + } + }; + + const a = architectures[arch]; + if (!a) return; + + // Ne jamais écraser le nom, l'hôte, les identifiants ou le from_domain de l'utilisateur ! + form.elements['port'].value = a.port; + form.elements['transport'].value = a.transport; + form.querySelectorAll('input[name="auth_mode"]').forEach(r => { r.checked = (r.value === a.auth_mode); }); + toggleTrunkAuthFields(a.auth_mode); + + form.elements['register'].checked = a.register; + form.elements['line_support'].checked = a.line_support; + form.elements['media_encryption'].value = a.media_encryption; + form.elements['nat_symmetric'].checked = a.nat_symmetric; + form.elements['qualify_frequency'].value = a.qualify_frequency; + + showToast(`Architecture « ${a.label} » appliquée sans écraser votre serveur ni vos identifiants.`, 'info'); +} + +function openTrunkModal(trunk = null) { + const form = document.getElementById('form-trunk'); + form.reset(); + switchTrunkModalTab('general'); + document.getElementById('freepbx-importer-box')?.classList.add('hidden'); + document.getElementById('modal-trunk-title').textContent = trunk ? `Modifier le Trunk ${trunk.name}` : 'Nouveau Trunk SIP / PJSIP Universel'; + + form.elements['id'].value = trunk ? trunk.id : ''; + form.elements['name'].value = trunk ? trunk.name : ''; + form.elements['host'].value = trunk ? trunk.host : ''; + form.elements['port'].value = trunk ? (trunk.port ?? 5060) : 5060; + form.elements['outbound_proxy'].value = trunk ? (trunk.outbound_proxy ?? '') : ''; + form.elements['transport'].value = trunk ? (trunk.transport ?? 'udp') : 'udp'; + + const authMode = trunk ? (trunk.auth_mode || 'userpass') : 'userpass'; + form.querySelectorAll('input[name="auth_mode"]').forEach(r => { + r.checked = (r.value === authMode); + }); + toggleTrunkAuthFields(authMode); + + form.elements['username'].value = trunk ? (trunk.username ?? '') : ''; + form.elements['auth_username'].value = trunk ? (trunk.auth_username ?? '') : ''; + form.elements['secret'].value = trunk ? (trunk.secret ?? '') : ''; + form.elements['enabled'].checked = trunk ? Number(trunk.enabled ?? 1) === 1 : true; + + // Onglet 2 : Registration & Identité SIP + form.elements['register'].checked = trunk ? Number(trunk.register ?? 1) === 1 : true; + form.elements['line_support'].checked = trunk ? Number(trunk.line_support ?? 1) === 1 : true; + form.elements['contact_user'].value = trunk ? (trunk.contact_user ?? '') : ''; + form.elements['expiration'].value = trunk ? (trunk.expiration ?? 3600) : 3600; + form.elements['retry_interval'].value = trunk ? (trunk.retry_interval ?? 60) : 60; + form.elements['max_retries'].value = trunk ? (trunk.max_retries ?? 10000) : 10000; + form.elements['forbidden_retry_interval'].value = trunk ? (trunk.forbidden_retry_interval ?? 600) : 600; + form.elements['fatal_retry_interval'].value = trunk ? (trunk.fatal_retry_interval ?? 0) : 0; + form.elements['server_uri'].value = trunk ? (trunk.server_uri ?? '') : ''; + form.elements['client_uri'].value = trunk ? (trunk.client_uri ?? '') : ''; + form.elements['from_user'].value = trunk ? (trunk.from_user ?? '') : ''; + form.elements['from_domain'].value = trunk ? (trunk.from_domain ?? '') : ''; + form.elements['outbound_callerid'].value = trunk ? (trunk.outbound_callerid ?? '') : ''; + form.elements['send_pai'].checked = trunk ? Number(trunk.send_pai ?? 1) === 1 : true; + form.elements['send_rpid'].checked = trunk ? Number(trunk.send_rpid ?? 0) === 1 : false; + form.elements['send_diversion'].checked = trunk ? Number(trunk.send_diversion ?? 1) === 1 : true; + form.elements['trust_id_inbound'].checked = trunk ? Number(trunk.trust_id_inbound ?? 1) === 1 : true; + + // Onglet 3 : Codecs, NAT, Timers, T.38 & Identify + form.elements['codecs'].value = trunk ? (trunk.codecs || 'alaw,ulaw,g722') : 'alaw,ulaw,g722'; + form.elements['dtmf_mode'].value = trunk ? (trunk.dtmf_mode || 'rfc4733') : 'rfc4733'; + form.elements['media_encryption'].value = trunk ? (trunk.media_encryption || 'no') : 'no'; + form.elements['qualify_frequency'].value = trunk ? (trunk.qualify_frequency ?? 60) : 60; + form.elements['rtp_keepalive'].value = trunk ? (trunk.rtp_keepalive ?? 15) : 15; + form.elements['max_channels'].value = trunk ? (trunk.max_channels ?? 0) : 0; + form.elements['timers'].value = trunk ? (trunk.timers || 'yes') : 'yes'; + form.elements['t38_udptl'].value = trunk ? (trunk.t38_udptl || 'no') : 'no'; + form.elements['language'].value = trunk ? (trunk.language || 'fr') : 'fr'; + form.elements['nat_symmetric'].checked = trunk ? Number(trunk.nat_symmetric ?? 1) === 1 : true; + form.elements['direct_media'].checked = trunk ? Number(trunk.direct_media ?? 0) === 1 : false; + form.elements['inband_progress'].checked = trunk ? Number(trunk.inband_progress ?? 0) === 1 : false; + form.elements['fax_detect'].checked = trunk ? Number(trunk.fax_detect ?? 0) === 1 : false; + form.elements['match_ips'].value = trunk ? (trunk.match_ips ?? '') : ''; + + // Contexte & Destination entrante (Totalement libres + assistés) + form.elements['context'].value = trunk ? (trunk.context || 'from-pstn-toheader') : 'from-pstn-toheader'; + populateInboundDestDropdown(trunk ? (trunk.inbound_dest || '101') : '101'); + + // Musique d'attente (MoH) spécifique à ce Trunk + const mohSelect = document.getElementById('trunk-moh-sound-id'); + if (mohSelect) { + const mohSounds = (appState.sounds || []).filter(s => s.usage_type === 'moh'); + const curMohId = trunk ? Number(trunk.moh_sound_id || 0) : 0; + let opts = ``; + mohSounds.forEach(s => { + const isSel = (Number(s.id) === curMohId) ? 'selected' : ''; + opts += ``; + }); + mohSelect.innerHTML = opts; + } + + // Onglet 4 : Routage SDA & Directives PJSIP Natives ([endpoint], [registration], [aor]) + form.elements['outbound_prefix'].value = trunk ? (trunk.outbound_prefix ?? '') : ''; + form.elements['custom_endpoint_params'].value = trunk ? (trunk.custom_endpoint_params ?? '') : ''; + form.elements['custom_reg_params'].value = trunk ? (trunk.custom_reg_params ?? '') : ''; + form.elements['custom_aor_params'].value = trunk ? (trunk.custom_aor_params ?? '') : ''; + + const filterInput = document.getElementById('inbound-routes-filter'); + if (filterInput) filterInput.value = ''; + + const didContainer = document.getElementById('trunk-did-container'); + if (didContainer) { + didContainer.innerHTML = ''; + const rawRoutes = trunk && trunk.did_routes ? trunk.did_routes : []; + let routesList = []; + + if (Array.isArray(rawRoutes)) { + routesList = rawRoutes; + } else if (typeof rawRoutes === 'object' && rawRoutes !== null) { + routesList = Object.entries(rawRoutes).map(([did, destination]) => ({ + cid: 'Any', + did, + description: `SDA ${did}`, + destination + })); + } + + if (routesList.length === 0) { + const defDest = trunk ? (trunk.inbound_dest || 'ext-101') : 'ext-101'; + const defDesc = trunk ? `Ligne ${trunk.name}` : 'Route Principale'; + addTrunkInboundRouteRow('Any', 'Any', defDesc, defDest, false); + } else { + routesList.forEach(r => { + addTrunkInboundRouteRow( + r.cid || 'Any', + r.did || 'Any', + r.description || '', + r.destination || 'ext-101', + false, + { + time_condition_enabled: Boolean(Number(r.time_condition_enabled)), + time_start: r.time_start || '08:30', + time_finish: r.time_finish || '18:00', + weekdays: r.weekdays || 'mon-fri', + time_dest_closed: r.time_dest_closed || 'terminate-hangup' + } + ); + }); + } + } + + openModal('modal-trunk'); +} + +function editTrunk(id, initialTab = 'general') { + const trunk = (appState.trunks || []).find(t => Number(t.id) === Number(id)); + if (trunk) { + openTrunkModal(trunk); + if (initialTab && initialTab !== 'general') { + switchTrunkModalTab(initialTab); + } + } +} + +async function deleteTrunk(id, optName = null) { + const t = (appState.trunks || []).find(tr => Number(tr.id) === Number(id)); + const name = optName || (t ? t.name : 'ce trunk'); + if (!confirm(`Supprimer le Trunk SIP ${name} ?`)) return; + try { + const res = await apiCall('delete_trunk', { id: Number(id) }); + showToast(res.message, 'success'); + await loadState(); + } catch (err) { + showToast(err.message, 'error'); + } +} + +// --- 3B. Rendu & CRUD Routes Sortantes (Outbound Routes & Failover Trunks) --- +function renderOutboundRoutesTable() { + const tbody = document.getElementById('outbound-routes-tbody'); + if (!tbody) return; + const routes = appState.outbound_routes || []; + + if (routes.length === 0) { + tbody.innerHTML = `Aucune route sortante configurée. Cliquez sur « + Ajout route sortante ».`; + return; + } + + tbody.innerHTML = routes.map((r, idx) => { + const trunkSeq = Array.isArray(r.trunk_sequence) ? r.trunk_sequence : []; + const trunkBadges = trunkSeq.length > 0 + ? trunkSeq.map((t, tIdx) => ` + + ${tIdx + 1}.${escapeHtml(t)} + + `).join(' → ') + : `⚠️ Aucun Trunk associé`; + + const isRestricted = r.permission_mode === 'restricted'; + const allowedGrps = Array.isArray(r.allowed_groups) ? r.allowed_groups : []; + const allowedExts = Array.isArray(r.allowed_extensions) ? r.allowed_extensions : []; + + let permBadge = ''; + if (!isRestricted) { + permBadge = `✓ Tous Postes & Groupes`; + } else { + const grpTags = allowedGrps.map(g => `Grp ${escapeHtml(g)}`).join(' '); + const extTags = allowedExts.length > 0 ? `${allowedExts.length} poste(s)` : ''; + permBadge = ` +
+ Restreint +
${grpTags} ${extTags}
+
+ `; + } + + const patterns = Array.isArray(r.dial_patterns) ? r.dial_patterns : []; + const patCount = patterns.length; + const patPreview = patterns.slice(0, 2).map(p => escapeHtml(p.match_pattern || '')).filter(Boolean).join(', '); + + const rTypeBadge = r.route_type === 'emergency' + ? `🚑 Urgence` + : (r.route_type === 'intracompany' + ? `Intra` + : `Standard`); + + const isFirst = idx === 0; + const isLast = idx === routes.length - 1; + + return ` + + +
+ #${Number(r.priority)} +
+ + +
+
+ + +
+ ${escapeHtml(r.name)} + ${rTypeBadge} +
+ + + ${r.outbound_callerid ? escapeHtml(r.outbound_callerid) : 'CID du poste ou du trunk'} + ${Number(r.override_ext_cid) === 1 ? '(Force)' : ''} + + +
${trunkBadges}
+ + ${permBadge} + +
${patCount} masque(s)
+
${patPreview}${patCount > 2 ? '...' : ''}
+ + + + + + + `; + }).join(''); +} + +let activeObAllowedExts = new Set(); +let activeObBlockedExts = new Set(); + +function switchOutboundModalTab(tabKey) { + document.querySelectorAll('.ob-subpanel').forEach(p => p.classList.add('hidden')); + const target = document.getElementById(`ob-panel-${tabKey}`); + if (target) target.classList.remove('hidden'); + + document.querySelectorAll('.ob-tab-btn').forEach(btn => { + if (btn.dataset.obTab === tabKey) { + btn.className = 'ob-tab-btn px-4 py-2 text-xs font-bold rounded-t-xl bg-indigo-600 text-white transition flex items-center gap-1.5'; + } else { + btn.className = 'ob-tab-btn px-4 py-2 text-xs font-semibold rounded-t-xl bg-anthracite-950 text-slate-300 hover:text-white transition flex items-center gap-1.5'; + } + }); +} + +function openOutboundRouteModal(route = null) { + const form = document.getElementById('form-outbound'); + form.reset(); + document.getElementById('modal-outbound-title').textContent = route ? `Éditer la Route Sortante : ${route.name}` : 'Ajout Route Sortante'; + + form.elements['id'].value = route ? route.id : ''; + form.elements['name'].value = route ? route.name : ''; + form.elements['outbound_callerid'].value = route ? route.outbound_callerid : ''; + form.elements['priority'].value = route ? route.priority : ((appState.outbound_routes || []).length + 1); + form.elements['route_password'].value = route ? route.route_password : ''; + form.elements['enabled'].checked = route ? Number(route.enabled) === 1 : true; + form.elements['continue_next_route'].checked = route ? Number(route.continue_next_route) === 1 : true; + + // Radios + const overrideCid = route ? String(route.override_ext_cid) : '0'; + form.querySelectorAll('input[name="override_ext_cid"]').forEach(r => r.checked = (r.value === overrideCid)); + + const routeType = route ? route.route_type : 'normal'; + form.querySelectorAll('input[name="route_type"]').forEach(r => r.checked = (r.value === routeType)); + + const permMode = route ? route.permission_mode : 'all'; + form.querySelectorAll('input[name="permission_mode"]').forEach(r => r.checked = (r.value === permMode)); + toggleOutboundPermissionMode(permMode); + + const recMode = route ? route.record_mode : 'dontcare'; + form.querySelectorAll('input[name="record_mode"]').forEach(r => r.checked = (r.value === recMode)); + + // Destination sur congestion + const congSel = document.getElementById('ob-congestion-dest-select'); + if (congSel) { + congSel.innerHTML = ` + + + + `; + } + + // Musique d'attente (MOH) + const mohSel = document.getElementById('ob-musicclass-select'); + if (mohSel) { + const curMoh = route ? (route.moh_class || 'default') : 'default'; + const mohSounds = (appState.sounds || []).filter(s => s.usage_type === 'moh'); + let mohOpts = ``; + mohSounds.forEach(s => { + const sSlug = slugifyTrunkName(s.site_category || 'global'); + const cName = (sSlug === 'global' || sSlug === 'default') ? 'default' : `moh-${sSlug}`; + if (cName !== 'default') { + mohOpts += ``; + } + }); + mohSel.innerHTML = mohOpts; + } + + // Séquence Trunks + const tSeqContainer = document.getElementById('ob-trunk-seq-container'); + tSeqContainer.innerHTML = ''; + const initialTrunks = route && Array.isArray(route.trunk_sequence) && route.trunk_sequence.length > 0 + ? route.trunk_sequence + : [(appState.trunks?.[0]?.name || '')]; + initialTrunks.forEach(tName => addOutboundTrunkSeqRow(tName)); + + // Dial Patterns + const pContainer = document.getElementById('ob-patterns-container'); + pContainer.innerHTML = ''; + const initialPatterns = route && Array.isArray(route.dial_patterns) && route.dial_patterns.length > 0 + ? route.dial_patterns + : [ + { prepend: '', prefix: '', match_pattern: '0[1-59]XXXXXXXX', callerid: '' }, + { prepend: '', prefix: '', match_pattern: '0[67]XXXXXXXX', callerid: '' } + ]; + initialPatterns.forEach(p => addOutboundPatternRow(p.prepend, p.prefix, p.match_pattern, p.callerid)); + updateOutboundPatternsBadge(); + + // Groupes d'extensions autorisés (Sites & Droits Sortants) + const grpsContainer = document.getElementById('ob-allowed-groups-container'); + const allowedGrpsSet = new Set(route && Array.isArray(route.allowed_groups) ? route.allowed_groups.map(String) : []); + const groups = appState.extension_groups || []; + if (groups.length === 0) { + grpsContainer.innerHTML = `Aucun groupe d'extensions configuré. Allez dans « Groupes » > « Groupes d'extensions » pour créer le Groupe Nice, Groupe Paris, etc.`; + } else { + grpsContainer.innerHTML = groups.map(g => { + const isChecked = allowedGrpsSet.has(String(g.id)) || allowedGrpsSet.has(String(g.name)); + const mCount = Array.isArray(g.members) ? g.members.length : 0; + return ` + + `; + }).join(''); + } + + // Extension Routing (Allowed Extensions <-> Blocked Extensions) + const allExts = appState.extensions || []; + const routeAllowedExts = route && Array.isArray(route.allowed_extensions) ? route.allowed_extensions.map(String) : null; + activeObAllowedExts = new Set(); + activeObBlockedExts = new Set(); + + allExts.forEach(e => { + const eStr = String(e.extension); + if (routeAllowedExts === null) { + // Par défaut, tous autorisés + activeObAllowedExts.add(eStr); + } else if (routeAllowedExts.includes(eStr)) { + activeObAllowedExts.add(eStr); + } else { + activeObBlockedExts.add(eStr); + } + }); + + renderOutboundExtensionBoxes(); + switchOutboundModalTab('settings'); + openModal('modal-outbound'); +} + +function editOutboundRoute(id) { + const route = (appState.outbound_routes || []).find(r => Number(r.id) === Number(id)); + if (route) openOutboundRouteModal(route); +} + +async function deleteOutboundRoute(id, optName = null) { + const r = (appState.outbound_routes || []).find(rt => Number(rt.id) === Number(id)); + const name = optName || (r ? r.name : 'cette route'); + if (!confirm(`Supprimer définitivement la Route Sortante « ${name} » ?`)) return; + try { + const res = await apiCall('delete_outbound_route', { id: Number(id) }); + showToast(res.message, 'success'); + await loadState(); + } catch (err) { + showToast(err.message, 'error'); + } +} + +async function reorderOutboundRoute(id, direction) { + try { + const res = await apiCall('reorder_outbound_route', { id, direction }); + showToast(res.message, 'success'); + await loadState(); + } catch (err) { + showToast(err.message, 'error'); + } +} + +function addOutboundTrunkSeqRow(selectedTrunk = '') { + const container = document.getElementById('ob-trunk-seq-container'); + if (!container) return; + const trunks = appState.trunks || []; + + const row = document.createElement('div'); + row.className = 'flex items-center gap-2 ob-trunk-seq-row'; + + const optsHtml = trunks.map(t => { + const isSel = (t.name === selectedTrunk) || (!selectedTrunk && trunks.indexOf(t) === 0); + return ``; + }).join(''); + + const currentCount = container.querySelectorAll('.ob-trunk-seq-row').length + 1; + row.innerHTML = ` + + ${currentCount} + + + + `; + + row.querySelector('.btn-del-trunk-row').addEventListener('click', () => { + if (container.querySelectorAll('.ob-trunk-seq-row').length <= 1) { + showToast('Une route sortante doit avoir au moins un Trunk.', 'error'); + return; + } + row.remove(); + container.querySelectorAll('.ob-trunk-seq-row').forEach((r, idx) => { + r.querySelector('span').textContent = String(idx + 1); + }); + }); + + container.appendChild(row); +} + +function updateOutboundPatternsBadge() { + const count = document.querySelectorAll('#ob-patterns-container .ob-pat-row').length; + const badge = document.getElementById('ob-patterns-count-badge'); + if (badge) badge.textContent = String(count); +} + +function addOutboundPatternRow(prepend = '', prefix = '', match = '', cid = '') { + const container = document.getElementById('ob-patterns-container'); + if (!container) return; + + const row = document.createElement('div'); + row.className = 'grid grid-cols-12 gap-2 px-4 py-2 items-center hover:bg-anthracite-800/60 transition ob-pat-row'; + row.innerHTML = ` +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+ `; + + row.querySelector('.btn-del-pat').addEventListener('click', () => { + row.remove(); + updateOutboundPatternsBadge(); + }); + + container.appendChild(row); + updateOutboundPatternsBadge(); +} + +function applyOutboundPatternPreset(presetKey) { + if (presetKey === 'fr_national') { + addOutboundPatternRow('', '', '0[1-59]XXXXXXXX', ''); + addOutboundPatternRow('', '', '0[67]XXXXXXXX', ''); + } else if (presetKey === 'fr_free') { + addOutboundPatternRow('', '', '080[0-9]XXXXXX', ''); + } else if (presetKey === 'fr_emergency') { + ['15', '17', '18', '112', '114'].forEach(num => addOutboundPatternRow('', '', num, '')); + } else if (presetKey === 'international') { + addOutboundPatternRow('', '', '00.', ''); + addOutboundPatternRow('', '', '+X.', ''); + } else if (presetKey === 'all') { + addOutboundPatternRow('', '', 'X.', ''); + } +} + +function toggleOutboundPermissionMode(mode) { + const details = document.getElementById('ob-permissions-details'); + if (!details) return; + if (mode === 'restricted') { + details.classList.remove('opacity-40', 'pointer-events-none'); + } else { + details.classList.add('opacity-40', 'pointer-events-none'); + } +} + +function renderOutboundExtensionBoxes() { + const allowedBox = document.getElementById('ob-allowed-exts-box'); + const blockedBox = document.getElementById('ob-blocked-exts-box'); + if (!allowedBox || !blockedBox) return; + + const allExts = appState.extensions || []; + let allowedHtml = ''; + let blockedHtml = ''; + + allExts.forEach(e => { + const eStr = String(e.extension); + if (activeObAllowedExts.has(eStr)) { + allowedHtml += ` + + `; + } else { + blockedHtml += ` + + `; + } + }); + + allowedBox.innerHTML = allowedHtml || `
Aucun poste individuel autorisé.
`; + blockedBox.innerHTML = blockedHtml || `
Tous les postes sont autorisés sur cette route !
`; + + document.getElementById('ob-allowed-exts-count').textContent = String(activeObAllowedExts.size); + document.getElementById('ob-blocked-exts-count').textContent = String(activeObBlockedExts.size); +} + +function obToggleSingleExt(eStr) { + if (activeObAllowedExts.has(eStr)) { + activeObAllowedExts.delete(eStr); + activeObBlockedExts.add(eStr); + } else { + activeObBlockedExts.delete(eStr); + activeObAllowedExts.add(eStr); + } + renderOutboundExtensionBoxes(); +} + +function obTransferExtensions(action) { + const allExts = appState.extensions || []; + if (action === 'all_allow') { + allExts.forEach(e => { + activeObAllowedExts.add(String(e.extension)); + activeObBlockedExts.delete(String(e.extension)); + }); + } else if (action === 'all_block') { + allExts.forEach(e => { + activeObBlockedExts.add(String(e.extension)); + activeObAllowedExts.delete(String(e.extension)); + }); + } else if (action === 'invert') { + allExts.forEach(e => { + const eStr = String(e.extension); + if (activeObAllowedExts.has(eStr)) { + activeObAllowedExts.delete(eStr); + activeObBlockedExts.add(eStr); + } else { + activeObBlockedExts.delete(eStr); + activeObAllowedExts.add(eStr); + } + }); + } + renderOutboundExtensionBoxes(); +} + +// --- 4. Rendu & CRUD SVI / IVR --- +function renderIvrsTable() { + const tbody = document.getElementById('ivrs-tbody'); + const rows = appState.ivrs || []; + + if (rows.length === 0) { + tbody.innerHTML = `Aucun serveur vocal interactif configuré. Cliquez sur « + Nouveau SVI / IVR » pour en créer un.`; + return; + } + + tbody.innerHTML = rows.map(ivr => { + const opts = ivr.options || {}; + const optEntries = Object.entries(opts); + const badges = optEntries.map(([k, v]) => { + let label = v; + if (v.startsWith('ext-')) label = `Poste ${v.replace('ext-', '')}`; + else if (v.startsWith('grp-')) label = `Groupe ${v.replace('grp-', '')}`; + else if (v.startsWith('sound-')) label = `Annonce`; + else if (v.startsWith('terminate-')) label = v.replace('terminate-', ''); + + return ` + ${escapeHtml(k)} + → + ${escapeHtml(label)} + `; + }).join(' '); + + const directDialBadge = Number(ivr.direct_dial) !== 0 + ? ` + + Activée + ` + : `Désactivée`; + + const testCode = '*8' + String(ivr.id).padStart(2, '0'); + + // Résolution du libellé de l'annonce audio + let audioName = escapeHtml(ivr.audio_file || 'demo-instruct'); + const foundSound = (appState.sounds || []).find(s => `custom/${s.slug}` === ivr.audio_file || s.slug === ivr.audio_file); + if (foundSound) { + audioName = `${escapeHtml(foundSound.name)} (${escapeHtml(foundSound.site_category)})`; + } + + return ` + + +
+ + ${escapeHtml(ivr.name)} +
+ ${ivr.description ? `
${escapeHtml(ivr.description)}
` : ''} + + +
+ + + + ${audioName} +
+ + ${directDialBadge} + +
+ ${badges || 'Aucune touche'} +
+ + +
⏱️ Attente : ${Number(ivr.timeout)}s (${Number(ivr.timeout_retries || 2)} rép)
+
⚠️ Erreur : max ${Number(ivr.invalid_retries || 3)} essais
+ + + + + ${testCode} + + + + + + + + `; + }).join(''); +} + +function addIvrOptionRow(digit = '', dest = '') { + const container = document.getElementById('ivr-options-container'); + const row = document.createElement('div'); + row.className = 'flex items-center gap-2.5 p-2 rounded-xl bg-anthracite-950/80 border border-anthracite-700/80 ivr-opt-row transition hover:border-indigo-500/50'; + + const digits = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0', '*', '#']; + let digitOptions = digits.map(d => ``).join(''); + + row.innerHTML = ` +
+ Touche : + +
+ → +
+ +
+ + `; + row.querySelector('button').addEventListener('click', () => row.remove()); + container.appendChild(row); +} + +function openIvrModal(ivr = null) { + const form = document.getElementById('form-ivr'); + form.reset(); + document.getElementById('modal-ivr-title').textContent = ivr ? `Modifier le SVI « ${ivr.name} »` : 'Nouveau Serveur Vocal Interactif (SVI / IVR)'; + + form.elements['id'].value = ivr ? ivr.id : ''; + form.elements['name'].value = ivr ? ivr.name : 'ivr-accueil'; + form.elements['description'].value = ivr && ivr.description ? ivr.description : ''; + form.elements['timeout'].value = ivr ? Number(ivr.timeout) : 5; + form.elements['timeout_retries'].value = ivr ? Number(ivr.timeout_retries || 2) : 2; + form.elements['invalid_retries'].value = ivr ? Number(ivr.invalid_retries || 3) : 3; + + // Numérotation directe (défaut activé si nouveau ou si direct_dial == 1) + const directDialCheck = document.getElementById('ivr-direct-dial-checkbox'); + if (directDialCheck) { + directDialCheck.checked = ivr ? Boolean(Number(ivr.direct_dial)) : true; + } + + // Sélecteur audio d'accueil (audio_file) + const audioSel = document.getElementById('ivr-audio-select'); + if (audioSel) { + const curAudio = ivr ? String(ivr.audio_file || '') : ''; + const sounds = appState.sounds || []; + let audioHtml = ''; + + if (sounds.length > 0) { + audioHtml += ``; + sounds.forEach(s => { + const val = `custom/${s.slug}`; + const isSel = curAudio === val || curAudio === s.slug || curAudio === String(s.id); + audioHtml += ``; + }); + audioHtml += ``; + } + + audioHtml += ` + + + + + + `; + audioSel.innerHTML = audioHtml; + } + + // Sélecteur message d'erreur invalide (invalid_sound_id) + const invalidSoundSel = document.getElementById('ivr-invalid-sound-select'); + if (invalidSoundSel) { + const curInvId = ivr ? Number(ivr.invalid_sound_id || 0) : 0; + let invSoundHtml = ``; + (appState.sounds || []).forEach(s => { + invSoundHtml += ``; + }); + invalidSoundSel.innerHTML = invSoundHtml; + } + + // Sélecteur destination d'échec invalide (invalid_dest) + const invalidDestSel = document.getElementById('ivr-invalid-dest-select'); + if (invalidDestSel) { + invalidDestSel.innerHTML = buildDestinationOptionsHtml(ivr ? (ivr.invalid_dest || 'terminate-hangup') : 'terminate-hangup'); + } + + // Sélecteur message timeout (timeout_sound_id) + const timeoutSoundSel = document.getElementById('ivr-timeout-sound-select'); + if (timeoutSoundSel) { + const curToutId = ivr ? Number(ivr.timeout_sound_id || 0) : 0; + let toutSoundHtml = ``; + (appState.sounds || []).forEach(s => { + toutSoundHtml += ``; + }); + timeoutSoundSel.innerHTML = toutSoundHtml; + } + + // Sélecteur destination d'échec timeout (timeout_dest) + const timeoutDestSel = document.getElementById('ivr-timeout-dest-select'); + if (timeoutDestSel) { + timeoutDestSel.innerHTML = buildDestinationOptionsHtml(ivr ? (ivr.timeout_dest || 'terminate-hangup') : 'terminate-hangup'); + } + + // Conteneur des touches DTMF + const container = document.getElementById('ivr-options-container'); + container.innerHTML = ''; + const opts = ivr && ivr.options && Object.keys(ivr.options).length > 0 + ? ivr.options + : { '1': 'ext-101', '2': 'grp-600' }; + Object.entries(opts).forEach(([d, dest]) => addIvrOptionRow(d, dest)); + + openModal('modal-ivr'); +} + +function editIvr(id) { + const ivr = (appState.ivrs || []).find(i => Number(i.id) === Number(id)); + if (ivr) openIvrModal(ivr); +} + +async function deleteIvr(id, optName = null) { + const ivr = (appState.ivrs || []).find(i => Number(i.id) === Number(id)); + const name = optName || (ivr ? ivr.name : 'ce SVI'); + if (!confirm(`Supprimer le SVI ${name} ?`)) return; + try { + const res = await apiCall('delete_ivr', { id: Number(id) }); + showToast(res.message, 'success'); + await loadState(); + } catch (err) { + showToast(err.message, 'error'); + } +} + +// --- 4B. Rendu & CRUD Médiathèque & Fichiers Sons (MoH, Annonces, IVR, Congestion) --- +const USAGE_LABELS = { + moh: { label: "Musique d'attente (MoH)", color: "amber" }, + announcement: { label: "Annonce d'accueil / Info", color: "indigo" }, + ivr: { label: "Menu SVI / Guide interactif", color: "cyan" }, + congestion: { label: "Ligne occupée / Congestion", color: "rose" }, + custom: { label: "Autre / Personnalisé", color: "slate" } +}; + +function formatDurationSec(sec) { + const s = Math.max(0, parseInt(sec, 10) || 0); + const m = Math.floor(s / 60); + const rem = s % 60; + return `${m}:${rem.toString().padStart(2, '0')}`; +} + +function formatBytes(bytes) { + const b = parseInt(bytes, 10) || 0; + if (b < 1024) return `${b} o`; + if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} Ko`; + return `${(b / (1024 * 1024)).toFixed(1)} Mo`; +} + +function renderSoundsTable() { + const tbody = document.getElementById('sounds-tbody'); + if (!tbody) return; + + const allSounds = appState.sounds || []; + const soundSites = appState.sound_sites || []; + + // Met à jour la liste des sites dans le filtre (sans icône) + const siteFilterEl = document.getElementById('filter-sound-site'); + if (siteFilterEl) { + const curVal = siteFilterEl.value || 'all'; + let siteOpts = ``; + soundSites.forEach(st => { + const isSel = (st.name === curVal) ? 'selected' : ''; + siteOpts += ``; + }); + siteFilterEl.innerHTML = siteOpts; + } + + // Met à jour le sélecteur de la Musique d'attente générale par défaut (MoH) + const defMohSelect = document.getElementById('global-default-moh-select'); + if (defMohSelect) { + const mohSounds = allSounds.filter(s => s.usage_type === 'moh'); + const curDefaultId = Number(appState.settings?.default_moh_sound_id || 0); + if (mohSounds.length === 0) { + defMohSelect.innerHTML = ``; + } else { + defMohSelect.innerHTML = mohSounds.map(s => { + const isSel = (Number(s.id) === curDefaultId) ? 'selected' : ''; + return ``; + }).join(''); + } + } + + // Récupération des filtres actifs + const selSite = siteFilterEl ? siteFilterEl.value : 'all'; + const selUsage = document.getElementById('filter-sound-usage')?.value || 'all'; + const searchTxt = (document.getElementById('filter-sound-search')?.value || '').toLowerCase().trim(); + + const filtered = allSounds.filter(s => { + if (selSite !== 'all' && s.site_category !== selSite) return false; + if (selUsage !== 'all' && s.usage_type !== selUsage) return false; + if (searchTxt) { + const hay = `${s.name} ${s.description} ${s.slug} ${s.site_category} ${s.usage_type}`.toLowerCase(); + if (!hay.includes(searchTxt)) return false; + } + return true; + }); + + if (filtered.length === 0) { + tbody.innerHTML = `Aucun fichier son trouvé pour les filtres sélectionnés. Cliquez sur « + Téléverser un Fichier Son ».`; + return; + } + + const curDefaultMohId = Number(appState.settings?.default_moh_sound_id || 0); + + tbody.innerHTML = filtered.map(snd => { + const usage = USAGE_LABELS[snd.usage_type] || USAGE_LABELS.custom; + const siteBadge = `${escapeHtml(snd.site_category || 'Global')}`; + const usageBadge = `${escapeHtml(usage.label || snd.usage_type)}`; + const defaultBadge = (Number(snd.id) === curDefaultMohId) + ? `Par défaut` + : ''; + + let astRef = `custom/${escapeHtml(snd.slug)}`; + if (snd.usage_type === 'moh') { + const sSlug = slugifyTrunkName(snd.site_category || 'global'); + astRef = (sSlug === 'global' || sSlug === 'default') ? 'default' : `moh-${sSlug}`; + } + + const audioUrl = `/api.php?action=stream_sound&id=${Number(snd.id)}`; + + return ` + + +
+ ${escapeHtml(snd.name)} + ${defaultBadge} +
+
${escapeHtml(snd.description || '—')}
+ + ${siteBadge} + ${usageBadge} + + + + +
${formatDurationSec(snd.duration_sec)}
+
${formatBytes(snd.file_size)} • ${escapeHtml((snd.format || 'wav').toUpperCase())}
+ + +
+ ${astRef} + +
+ + + + + + + `; + }).join(''); +} + +async function applyDefaultMohFromSelect() { + const sel = document.getElementById('global-default-moh-select'); + if (!sel || !sel.value) { + showToast("Veuillez sélectionner un fichier son pour la musique d'attente par défaut.", 'error'); + return; + } + try { + const res = await apiCall('set_default_moh', { sound_id: Number(sel.value) }); + showToast(res.message, 'success'); + await loadState(); + } catch (err) { + showToast(err.message, 'error'); + } +} + +// --- Gestion des catégories de sites (Création, Modification, Suppression) --- +function openSoundSitesModal() { + const form = document.getElementById('form-sound-site'); + if (form) form.reset(); + const idInput = document.getElementById('sound-site-id'); + if (idInput) idInput.value = ''; + const btn = document.getElementById('btn-save-sound-site'); + if (btn) btn.textContent = 'Enregistrer'; + renderSoundSitesTable(); + openModal('modal-sound-sites'); +} + +function renderSoundSitesTable() { + const tbody = document.getElementById('sound-sites-tbody'); + if (!tbody) return; + + const sites = appState.sound_sites || []; + const sounds = appState.sounds || []; + + if (sites.length === 0) { + tbody.innerHTML = `Aucune catégorie de site définie.`; + return; + } + + tbody.innerHTML = sites.map(st => { + const count = sounds.filter(s => (s.site_category || '').toLowerCase() === (st.name || '').toLowerCase()).length; + const isGlobal = (st.name || '').toLowerCase() === 'global'; + + const deleteBtn = isGlobal + ? `Protégé` + : ``; + + return ` + + + ${escapeHtml(st.name)} + ${isGlobal ? 'Système' : ''} + + ${escapeHtml(st.description || '—')} + + ${count} fichier${count > 1 ? 's' : ''} + + + + ${deleteBtn} + + + `; + }).join(''); +} + +function editSoundSite(id) { + const site = (appState.sound_sites || []).find(s => Number(s.id) === Number(id)); + if (!site) return; + const idInput = document.getElementById('sound-site-id'); + const nameInput = document.getElementById('sound-site-name'); + const descInput = document.getElementById('sound-site-desc'); + const btn = document.getElementById('btn-save-sound-site'); + + if (idInput) idInput.value = site.id; + if (nameInput) { + nameInput.value = site.name; + nameInput.focus(); + } + if (descInput) descInput.value = site.description || ''; + if (btn) btn.textContent = 'Mettre à jour'; +} + +async function deleteSoundSite(id, optName = null) { + const st = (appState.sound_sites || []).find(s => Number(s.id) === Number(id)); + const name = optName || (st ? st.name : 'ce site'); + if (!confirm(`Supprimer la catégorie de site « ${name} » ?\nTous les sons associés seront automatiquement réassignés à « Global ».`)) return; + try { + const res = await apiCall('delete_sound_site', { id: Number(id) }); + showToast(res.message, 'success'); + await loadState(); + renderSoundSitesTable(); + } catch (err) { + showToast(err.message, 'error'); + } +} + +function handleSoundFileSelected(input) { + const file = input.files?.[0]; + const placeEl = document.getElementById('sound-upload-placeholder'); + const prevEl = document.getElementById('sound-upload-preview'); + const nameEl = document.getElementById('sound-upload-filename'); + const sizeEl = document.getElementById('sound-upload-filesize'); + const nameInput = document.getElementById('sound-input-name'); + + if (file) { + placeEl?.classList.add('hidden'); + prevEl?.classList.remove('hidden'); + if (nameEl) nameEl.textContent = file.name; + if (sizeEl) sizeEl.textContent = formatBytes(file.size); + + if (nameInput && !nameInput.value.trim()) { + const cleanName = file.name.replace(/\.[^/.]+$/, '').replace(/[-_]+/g, ' '); + nameInput.value = cleanName.charAt(0).toUpperCase() + cleanName.slice(1); + } + } else { + placeEl?.classList.remove('hidden'); + prevEl?.classList.add('hidden'); + } +} + +function openSoundModal(snd = null) { + const form = document.getElementById('form-sound'); + form.reset(); + document.getElementById('modal-sound-title').textContent = snd ? `Modifier le fichier « ${snd.name} »` : "Téléverser un Fichier Son"; + document.getElementById('btn-submit-sound').textContent = snd ? "Enregistrer les Modifications" : "Téléverser & Enregistrer"; + + form.elements['id'].value = snd ? snd.id : ''; + form.elements['name'].value = snd ? snd.name : ''; + form.elements['usage_type'].value = snd ? snd.usage_type : 'announcement'; + form.elements['description'].value = snd ? snd.description : ''; + + // Remplit la liste déroulante des catégories de sites + const siteSelect = document.getElementById('sound-input-site'); + if (siteSelect) { + const sites = appState.sound_sites || []; + const curSite = snd ? snd.site_category : 'Global'; + siteSelect.innerHTML = sites.map(st => { + const isSel = (st.name === curSite) ? 'selected' : ''; + return ``; + }).join(''); + if (curSite && !sites.some(st => st.name === curSite)) { + siteSelect.insertAdjacentHTML('afterbegin', ``); + } + } + + const uploadBlock = document.getElementById('sound-file-upload-block'); + const fileInput = document.getElementById('sound-audio-file-input'); + const placeEl = document.getElementById('sound-upload-placeholder'); + const prevEl = document.getElementById('sound-upload-preview'); + + if (snd) { + if (uploadBlock) uploadBlock.classList.add('hidden'); + if (fileInput) fileInput.removeAttribute('required'); + } else { + if (uploadBlock) uploadBlock.classList.remove('hidden'); + if (fileInput) fileInput.setAttribute('required', 'required'); + placeEl?.classList.remove('hidden'); + prevEl?.classList.add('hidden'); + } + + openModal('modal-sound'); +} + +function editSound(id) { + const snd = (appState.sounds || []).find(s => Number(s.id) === Number(id)); + if (snd) openSoundModal(snd); +} + +async function deleteSound(id, optName = null) { + const snd = (appState.sounds || []).find(s => Number(s.id) === Number(id)); + const name = optName || (snd ? snd.name : 'ce fichier son'); + if (!confirm(`Supprimer définitivement le fichier son « ${name} » et ses encodages Asterisk ?`)) return; + try { + const res = await apiCall('delete_sound', { id: Number(id) }); + showToast(res.message, 'success'); + await loadState(); + } catch (err) { + showToast(err.message, 'error'); + } +} + +// --- 5. Rendu Paramètres, Diagnostic Debian & Prévisualisations .conf --- +function renderSettingsAndDiagnostics() { + const s = appState.settings || {}; + const form = document.getElementById('settings-form'); + if (form) { + if (form.elements['company_name']) { + form.elements['company_name'].value = s.company_name || ''; + } + if (form.elements['system_language']) { + form.elements['system_language'].value = s.system_language || 'fr'; + } + form.elements['domain'].value = s.domain || 'pbx.local'; + form.elements['letsencrypt_domain'].value = s.letsencrypt_domain || ''; + form.elements['local_net'].value = s.local_net || '10.10.0.0/16'; + form.elements['external_ip'].value = s.external_ip || ''; + form.elements['udp_port'].value = s.udp_port || 5060; + form.elements['tls_port'].value = s.tls_port || 5061; + form.elements['ami_host'].value = s.ami_host || '127.0.0.1'; + form.elements['ami_port'].value = s.ami_port || 5038; + form.elements['ami_user'].value = s.ami_user || 'pbxgui'; + form.elements['ami_secret'].value = s.ami_secret || ''; + form.elements['new_admin_password'].value = ''; + } + + const diagList = document.getElementById('diagnostics-list'); + const checks = appState.diagnostics?.checks || []; + diagList.innerHTML = checks.map(c => ` +
+ + ${c.ok ? '✓' : '!'} + +
+
${escapeHtml(c.label)}
+
${escapeHtml(c.detail)}
+
+
+ `).join(''); + + document.getElementById('preview-pjsip').textContent = appState.previews?.pjsip || ''; + document.getElementById('preview-extensions').textContent = appState.previews?.extensions || ''; + const prevVm = document.getElementById('preview-voicemail'); + if (prevVm) { + prevVm.textContent = appState.previews?.voicemail || ''; + } +} + +// --- Navigation Sidebar & Modales --- +const PAGE_TITLES = { + dashboard: 'Tableau de Bord & Supervision Temps Réel', + extensions: 'Postes Téléphoniques SIP (PJSIP)', + groups: "Groupes de Sonnerie (Ring Groups)", + extgroups: "Groupes d'Extensions (Sites & Droits Sortants)", + trunks: 'Trunks Opérateurs SIP', + outbound: 'Routes Sortantes (Outbound Routes & Séquence Trunks)', + ivrs: 'Serveurs Vocaux Interactifs (SVI / IVR)', + sounds: 'Fichiers sons', + featurecodes: 'Codes de Fonctions & Services Téléphoniques Asterisk', + sipsettings: 'Paramètres SIP Globaux & Configuration NAT (PJSIP & RTP)', + certificates: 'Gestionnaire de Certificats SSL / TLS (Let\'s Encrypt & Imports)', + system: 'Paramètres Globaux, Diagnostic Debian & Fichiers .conf', + backups: 'Sauvegardes & Restauration Système (Disaster Recovery)', + firewall: 'Pare-feu Réseau & Sécurité Téléphonie (Fail2ban & NFTables)' +}; + +function switchTab(tabId) { + document.querySelectorAll('.tab-panel').forEach(p => p.classList.add('hidden')); + const target = document.getElementById(`tab-${tabId}`); + if (target) target.classList.remove('hidden'); + + const titleEl = document.getElementById('page-header-title'); + if (titleEl && PAGE_TITLES[tabId]) { + titleEl.textContent = PAGE_TITLES[tabId]; + } + + document.querySelectorAll('.tab-btn').forEach(btn => { + const isSubBtn = btn.parentElement?.id === 'submenu-operators' || btn.parentElement?.id === 'submenu-groups' || btn.parentElement?.id === 'submenu-system'; + if (btn.dataset.tab === tabId) { + if (isSubBtn) { + btn.className = 'tab-btn w-full flex items-center justify-between px-3 py-1.5 rounded-lg text-xs font-bold bg-indigo-600 text-white shadow-sm transition text-left'; + } else { + btn.className = 'tab-btn w-full flex items-center justify-between px-3.5 py-2.5 rounded-xl text-sm font-semibold bg-indigo-600 text-white shadow-md shadow-indigo-600/20 transition'; + } + } else { + if (isSubBtn) { + btn.className = 'tab-btn w-full flex items-center justify-between px-3 py-1.5 rounded-lg text-xs font-medium text-slate-300 hover:bg-anthracite-800 hover:text-white transition text-left'; + } else { + btn.className = 'tab-btn w-full flex items-center justify-between px-3.5 py-2.5 rounded-xl text-sm font-medium text-slate-300 hover:bg-anthracite-800 hover:text-white transition'; + } + } + }); + + // Si on clique sur un sous-menu Opérateurs, s'assurer que le menu parent est bien déplié + if (tabId === 'trunks' || tabId === 'outbound') { + const submenu = document.getElementById('submenu-operators'); + const arrow = document.getElementById('arrow-menu-operators'); + if (submenu && submenu.classList.contains('hidden')) { + submenu.classList.remove('hidden'); + arrow?.classList.remove('-rotate-90'); + } + } + + // Si on clique sur un sous-menu Groupes, s'assurer que le menu parent est bien déplié + if (tabId === 'groups' || tabId === 'extgroups') { + const submenu = document.getElementById('submenu-groups'); + const arrow = document.getElementById('arrow-menu-groups'); + if (submenu && submenu.classList.contains('hidden')) { + submenu.classList.remove('hidden'); + arrow?.classList.remove('-rotate-90'); + } + } + + // Si on clique sur un sous-menu Système, s'assurer que le menu parent est bien déplié + if (tabId === 'system' || tabId === 'backups' || tabId === 'firewall') { + const submenu = document.getElementById('submenu-system'); + const arrow = document.getElementById('arrow-menu-system'); + if (submenu && submenu.classList.contains('hidden')) { + submenu.classList.remove('hidden'); + arrow?.classList.remove('-rotate-90'); + } + } + + // Si onglet Sauvegardes, charger la liste des sauvegardes et la configuration + if (tabId === 'backups') { + loadBackups(); + } + + // Si onglet Pare-feu, charger le statut en direct + if (tabId === 'firewall') { + loadFirewallStatus(); + } + + toggleMobileSidebar(false); +} + +function toggleSystemMenu() { + const submenu = document.getElementById('submenu-system'); + const arrow = document.getElementById('arrow-menu-system'); + if (!submenu) return; + const isHidden = submenu.classList.toggle('hidden'); + if (arrow) { + if (isHidden) { + arrow.classList.add('-rotate-90'); + } else { + arrow.classList.remove('-rotate-90'); + } + } +} + +function toggleGroupsMenu() { + const submenu = document.getElementById('submenu-groups'); + const arrow = document.getElementById('arrow-menu-groups'); + if (!submenu) return; + const isHidden = submenu.classList.toggle('hidden'); + if (arrow) { + if (isHidden) { + arrow.classList.add('-rotate-90'); + } else { + arrow.classList.remove('-rotate-90'); + } + } +} + +function toggleOperatorsMenu() { + const submenu = document.getElementById('submenu-operators'); + const arrow = document.getElementById('arrow-menu-operators'); + if (!submenu) return; + const isHidden = submenu.classList.toggle('hidden'); + if (arrow) { + if (isHidden) { + arrow.classList.add('-rotate-90'); + } else { + arrow.classList.remove('-rotate-90'); + } + } +} + +function toggleMobileSidebar(open) { + const sidebar = document.getElementById('sidebar'); + const backdrop = document.getElementById('sidebar-backdrop'); + if (!sidebar || !backdrop) return; + + if (open) { + sidebar.classList.remove('-translate-x-full'); + backdrop.classList.remove('hidden'); + } else { + sidebar.classList.add('-translate-x-full'); + backdrop.classList.add('hidden'); + } +} + +function openModal(id) { + const el = document.getElementById(id); + if (el) { + el.classList.remove('hidden'); + el.classList.add('flex'); + } +} + +function closeModal(id) { + const el = document.getElementById(id); + if (el) { + el.classList.add('hidden'); + el.classList.remove('flex'); + } +} + +// --- Écouteurs d'événements DOM --- +document.addEventListener('DOMContentLoaded', () => { + // Login AJAX + document.getElementById('login-form')?.addEventListener('submit', async (e) => { + e.preventDefault(); + const formData = Object.fromEntries(new FormData(e.target).entries()); + try { + const res = await apiCall('login', formData); + showToast(res.message, 'success'); + document.getElementById('login-screen').classList.add('hidden'); + document.getElementById('app-screen').classList.remove('hidden'); + await loadState(); + } catch (err) { + showToast(err.message, 'error'); + } + }); + + // Logout AJAX + document.getElementById('btn-logout')?.addEventListener('click', async () => { + stopMetricsPolling(); + await apiCall('logout', {}); + document.getElementById('app-screen').classList.add('hidden'); + document.getElementById('login-screen').classList.remove('hidden'); + showToast('Vous êtes déconnecté.', 'info'); + }); + + // Form Extension + document.getElementById('form-extension')?.addEventListener('submit', async (e) => { + e.preventDefault(); + const fd = new FormData(e.target); + const payload = Object.fromEntries(fd.entries()); + payload.encryption = Number(document.getElementById('ext-encryption')?.value ?? 1); + payload.member_extension_groups = Array.from(e.target.querySelectorAll('input[name="member_extension_groups[]"]:checked')).map(c => c.value); + payload.voicemail_enabled = document.getElementById('ext-voicemail-enabled')?.checked ? 1 : 0; + payload.force_pin_change = (payload.pin_mode === 'first_call') ? 1 : 0; + payload.voicemail_skip_pin = document.getElementById('ext-skip-pin')?.checked ? 1 : 0; + payload.voicemail_attach = document.getElementById('ext-vm-attach')?.checked ? 1 : 0; + payload.voicemail_delete = document.getElementById('ext-vm-delete')?.checked ? 1 : 0; + payload.ring_time = Number(payload.ring_time || 20); + + try { + const res = await apiCall('save_extension', payload); + showToast(res.message, 'success'); + closeModal('modal-extension'); + await loadState(); + } catch (err) { + showToast(err.message, 'error'); + } + }); + + // Form Groupe de Sonnerie (Ring Groups) + document.getElementById('form-group')?.addEventListener('submit', async (e) => { + e.preventDefault(); + const fd = new FormData(e.target); + const payload = Object.fromEntries(fd.entries()); + payload.members = Array.from(e.target.querySelectorAll('input[name="member_ext"]:checked')).map(c => c.value); + + try { + const res = await apiCall('save_group', payload); + showToast(res.message, 'success'); + closeModal('modal-group'); + await loadState(); + } catch (err) { + showToast(err.message, 'error'); + } + }); + + // Form Groupe d'Extensions (Sites & Droits Sortants) + document.getElementById('form-extgroup')?.addEventListener('submit', async (e) => { + e.preventDefault(); + const fd = new FormData(e.target); + const payload = Object.fromEntries(fd.entries()); + payload.members = Array.from(activeExtGroupSelectedExts); + + try { + const res = await apiCall('save_extension_group', payload); + showToast(res.message, 'success'); + closeModal('modal-extgroup'); + await loadState(); + } catch (err) { + showToast(err.message, 'error'); + } + }); + + // Form Trunk (Configuration PJSIP complète) + document.getElementById('form-trunk')?.addEventListener('submit', async (e) => { + e.preventDefault(); + const form = e.target; + const fd = new FormData(form); + const payload = Object.fromEntries(fd.entries()); + + payload.enabled = form.elements['enabled'].checked ? 1 : 0; + payload.register = form.elements['register'].checked ? 1 : 0; + payload.line_support = form.elements['line_support'].checked ? 1 : 0; + payload.send_pai = form.elements['send_pai'].checked ? 1 : 0; + payload.send_rpid = form.elements['send_rpid'].checked ? 1 : 0; + payload.send_diversion = form.elements['send_diversion'].checked ? 1 : 0; + payload.trust_id_inbound = form.elements['trust_id_inbound'].checked ? 1 : 0; + payload.nat_symmetric = form.elements['nat_symmetric'].checked ? 1 : 0; + payload.direct_media = form.elements['direct_media'].checked ? 1 : 0; + payload.inband_progress = form.elements['inband_progress'].checked ? 1 : 0; + payload.fax_detect = form.elements['fax_detect'].checked ? 1 : 0; + + const didRoutes = []; + document.querySelectorAll('#trunk-did-container .trunk-did-row').forEach(row => { + const cid = row.querySelector('.trunk-did-cid')?.value.trim() || 'Any'; + const did = row.querySelector('.trunk-did-num')?.value.trim() || 'Any'; + const desc = row.querySelector('.trunk-did-desc')?.value.trim() || ''; + const dest = row.querySelector('.trunk-did-dest')?.value.trim() || ''; + const tcEnabled = row.querySelector('.trunk-tc-enabled')?.checked ? 1 : 0; + const tcStart = row.querySelector('.trunk-tc-start')?.value || '08:30'; + const tcFinish = row.querySelector('.trunk-tc-finish')?.value || '18:00'; + const weekdays = row.querySelector('.trunk-tc-weekdays')?.value || 'mon-fri'; + const tcClosed = row.querySelector('.trunk-tc-closed-dest')?.value || 'terminate-hangup'; + + if (dest) { + didRoutes.push({ + cid, + did, + description: desc, + destination: dest, + time_condition_enabled: tcEnabled, + time_start: tcStart, + time_finish: tcFinish, + weekdays: weekdays, + time_dest_closed: tcClosed + }); + } + }); + payload.did_routes = didRoutes; + + try { + const res = await apiCall('save_trunk', payload); + showToast(res.message, 'success'); + closeModal('modal-trunk'); + await loadState(); + } catch (err) { + showToast(err.message, 'error'); + } + }); + + // Form Outbound Route + document.getElementById('form-outbound')?.addEventListener('submit', async (e) => { + e.preventDefault(); + const form = e.target; + const fd = new FormData(form); + const payload = Object.fromEntries(fd.entries()); + + payload.enabled = form.elements['enabled'].checked ? 1 : 0; + payload.continue_next_route = form.elements['continue_next_route'].checked ? 1 : 0; + payload.override_ext_cid = form.querySelector('input[name="override_ext_cid"]:checked')?.value === '1' ? 1 : 0; + payload.route_type = form.querySelector('input[name="route_type"]:checked')?.value || 'normal'; + payload.permission_mode = form.querySelector('input[name="permission_mode"]:checked')?.value || 'all'; + payload.record_mode = form.querySelector('input[name="record_mode"]:checked')?.value || 'dontcare'; + + // Séquence de Trunks + const trunks = []; + document.querySelectorAll('#ob-trunk-seq-container .ob-trunk-select').forEach(sel => { + const val = sel.value.trim(); + if (val && !trunks.includes(val)) trunks.push(val); + }); + payload.trunk_sequence = trunks; + + // Dial Patterns + const patterns = []; + document.querySelectorAll('#ob-patterns-container .ob-pat-row').forEach(row => { + const prep = row.querySelector('.ob-pat-prepend')?.value.trim() || ''; + const pfx = row.querySelector('.ob-pat-prefix')?.value.trim() || ''; + const mtch = row.querySelector('.ob-pat-match')?.value.trim() || ''; + const cid = row.querySelector('.ob-pat-cid')?.value.trim() || ''; + if (mtch) { + patterns.push({ + prepend: prep, + prefix: pfx, + match_pattern: mtch, + callerid: cid + }); + } + }); + payload.dial_patterns = patterns; + + // Groupes autorisés + payload.allowed_groups = Array.from(form.querySelectorAll('input[name="ob_allowed_groups[]"]:checked')).map(c => c.value); + + // Extensions autorisées (depuis le drag/transfert interactif) + payload.allowed_extensions = Array.from(activeObAllowedExts); + + try { + const res = await apiCall('save_outbound_route', payload); + showToast(res.message, 'success'); + closeModal('modal-outbound'); + await loadState(); + } catch (err) { + showToast(err.message, 'error'); + } + }); + + // Form IVR + document.getElementById('form-ivr')?.addEventListener('submit', async (e) => { + e.preventDefault(); + const fd = new FormData(e.target); + const payload = Object.fromEntries(fd.entries()); + payload.direct_dial = document.getElementById('ivr-direct-dial-checkbox')?.checked ? 1 : 0; + + const options = {}; + document.querySelectorAll('#ivr-options-container .ivr-opt-row').forEach(row => { + const d = (row.querySelector('.ivr-digit')?.value || '').trim(); + const dest = (row.querySelector('.ivr-dest')?.value || '').trim(); + if (d && dest) options[d] = dest; + }); + payload.options = options; + + try { + const res = await apiCall('save_ivr', payload); + showToast(res.message, 'success'); + closeModal('modal-ivr'); + await loadState(); + } catch (err) { + showToast(err.message, 'error'); + } + }); + + // Form Fichier Son (Upload multipart / Update) + document.getElementById('form-sound')?.addEventListener('submit', async (e) => { + e.preventDefault(); + const form = e.target; + const fd = new FormData(form); + const id = form.elements['id'].value; + const submitBtn = document.getElementById('btn-submit-sound'); + + if (submitBtn) { + submitBtn.disabled = true; + submitBtn.textContent = "Traitement audio en cours..."; + } + + try { + let res; + if (id) { + const payload = Object.fromEntries(fd.entries()); + res = await apiCall('update_sound', payload); + } else { + const response = await fetch('/api.php?action=upload_sound', { + method: 'POST', + body: fd + }); + const cType = response.headers.get('content-type') || ''; + if (cType.includes('application/json')) { + res = await response.json(); + } else { + const rawText = await response.text(); + if (response.status === 413) { + throw new Error("Le fichier audio est trop volumineux pour le serveur (Limite 64 Mo)."); + } + throw new Error(`Erreur serveur (${response.status}) : ${rawText.replace(/<[^>]*>/g, '').trim().slice(0, 150) || 'Réponse inattendue'}`); + } + if (!response.ok || !res.success) { + throw new Error(res.message || 'Erreur lors du téléversement du fichier audio.'); + } + } + showToast(res.message, 'success'); + closeModal('modal-sound'); + await loadState(); + } catch (err) { + showToast(err.message, 'error'); + } finally { + if (submitBtn) { + submitBtn.disabled = false; + submitBtn.textContent = id ? "Enregistrer les Modifications" : "Téléverser & Enregistrer"; + } + } + }); + + // Form Catégories de sites + document.getElementById('form-sound-site')?.addEventListener('submit', async (e) => { + e.preventDefault(); + const form = e.target; + const payload = Object.fromEntries(new FormData(form).entries()); + try { + const res = await apiCall('save_sound_site', payload); + showToast(res.message, 'success'); + form.reset(); + const idInput = document.getElementById('sound-site-id'); + if (idInput) idInput.value = ''; + const btn = document.getElementById('btn-save-sound-site'); + if (btn) btn.textContent = 'Enregistrer'; + await loadState(); + renderSoundSitesTable(); + } catch (err) { + showToast(err.message, 'error'); + } + }); + + // Form Settings + document.getElementById('settings-form')?.addEventListener('submit', async (e) => { + e.preventDefault(); + const payload = Object.fromEntries(new FormData(e.target).entries()); + payload.wizard_completed = 1; + try { + const res = await apiCall('save_settings', payload); + showToast(res.message, 'success'); + await loadState(); + } catch (err) { + showToast(err.message, 'error'); + } + }); + + // Bouton Tester AMI + document.getElementById('btn-test-ami')?.addEventListener('click', async () => { + try { + const res = await apiCall('test_ami'); + showToast(res.message, 'success'); + } catch (err) { + showToast(err.message, 'error'); + } + }); + + // Bouton Appliquer les configurations (Écriture /etc/asterisk + Reload AMI) + document.getElementById('btn-apply-config')?.addEventListener('click', async () => { + const icon = document.getElementById('apply-icon'); + icon?.classList.add('animate-spin'); + try { + const res = await apiCall('apply_config', {}); + showToast(res.message, 'success'); + await loadState(); + } catch (err) { + showToast(err.message, 'error'); + } finally { + icon?.classList.remove('animate-spin'); + } + }); + + // Formulaire Let's Encrypt + document.getElementById('form-cert-letsencrypt')?.addEventListener('submit', async (e) => { + e.preventDefault(); + const f = e.target; + const btn = document.getElementById('btn-submit-letsencrypt'); + const spinner = document.getElementById('spinner-letsencrypt'); + const btnText = document.getElementById('btn-text-letsencrypt'); + const msgBox = document.getElementById('letsencrypt-msg-box'); + + const formData = new FormData(f); + const payload = { + name: (formData.get('name') || '').trim(), + domain: (formData.get('domain') || '').trim(), + email: (formData.get('email') || '').trim(), + is_default: formData.get('is_default') === '1' ? 1 : 0, + staging: formData.get('staging') === '1' ? 1 : 0 + }; + + if (btn) btn.disabled = true; + if (spinner) spinner.classList.remove('hidden'); + if (btnText) btnText.textContent = "Communication avec Let's Encrypt..."; + if (msgBox) { msgBox.className = 'hidden'; msgBox.textContent = ''; } + + try { + const res = await apiCall('request_letsencrypt_cert', payload); + showToast(res.message, 'success'); + closeModal('modal-cert-letsencrypt'); + await loadState(); + } catch (err) { + if (msgBox) { + msgBox.className = 'p-3 rounded-xl text-xs font-mono bg-rose-950/60 border border-rose-500/40 text-rose-300'; + msgBox.textContent = "Erreur Let's Encrypt :\n" + err.message; + } + showToast("Échec de l'émission Let's Encrypt", 'error'); + } finally { + if (btn) btn.disabled = false; + if (spinner) spinner.classList.add('hidden'); + if (btnText) btnText.textContent = "Lancer l'émission ACME"; + } + }); + + // Formulaire Import Certificat Custom (supporte FormData multipart pour fichiers .crt, .key) + document.getElementById('form-cert-custom')?.addEventListener('submit', async (e) => { + e.preventDefault(); + const f = e.target; + const btn = document.getElementById('btn-submit-custom-cert'); + const spinner = document.getElementById('spinner-custom-cert'); + const msgBox = document.getElementById('custom-cert-msg-box'); + + const formData = new FormData(f); + + if (btn) btn.disabled = true; + if (spinner) spinner.classList.remove('hidden'); + if (msgBox) { msgBox.className = 'hidden'; msgBox.textContent = ''; } + + try { + const resp = await fetch('/api.php?action=save_custom_cert', { + method: 'POST', + body: formData + }); + const data = await resp.json(); + if (!data.success) { + throw new Error(data.message || 'Erreur lors de l\'enregistrement du certificat'); + } + showToast(data.message, 'success'); + closeModal('modal-cert-custom'); + await loadState(); + } catch (err) { + if (msgBox) { + msgBox.className = 'p-3 rounded-xl text-xs font-mono bg-rose-950/60 border border-rose-500/40 text-rose-300'; + msgBox.textContent = "Erreur de validation du certificat :\n" + err.message; + } + showToast(err.message, 'error'); + } finally { + if (btn) btn.disabled = false; + if (spinner) spinner.classList.add('hidden'); + } + }); + + // Lecture automatique des fichiers .crt, .key dans les textareas lors de la sélection + const bindFileReader = (fileInputId, textareaId) => { + const input = document.getElementById(fileInputId); + const textarea = document.getElementById(textareaId); + if (input && textarea) { + input.addEventListener('change', (e) => { + const file = e.target.files[0]; + if (file) { + const reader = new FileReader(); + reader.onload = (re) => { textarea.value = re.target.result; }; + reader.readAsText(file); + } + }); + } + }; + bindFileReader('file-cert-custom', 'textarea-cert-pem'); + bindFileReader('file-key-custom', 'textarea-key-pem'); + bindFileReader('file-chain-custom', 'textarea-chain-pem'); + + // Formulaire Paramètres SIP & NAT + document.getElementById('form-sip-settings')?.addEventListener('submit', async (e) => { + e.preventDefault(); + const f = e.target; + const btn = document.getElementById('btn-save-sip-settings'); + const spinner = document.getElementById('spinner-save-sip-settings'); + + const netInputs = f.querySelectorAll('input[name="local_networks[]"]'); + const localNets = Array.from(netInputs).map(i => i.value.trim()).filter(v => v !== ''); + + const payload = { + external_ip: (document.getElementById('sip-external-ip')?.value || '').trim(), + external_udp_port: Number(document.getElementById('sip-external-udp-port')?.value || 0), + external_tls_port: Number(document.getElementById('sip-external-tls-port')?.value || 0), + udp_port: Number(document.getElementById('sip-udp-port')?.value || 5060), + tls_port: Number(document.getElementById('sip-tls-port')?.value || 5061), + rtp_start: Number(document.getElementById('sip-rtp-start')?.value || 10000), + rtp_end: Number(document.getElementById('sip-rtp-end')?.value || 20000), + rtp_strict: Number(document.getElementById('sip-rtp-strict')?.value || 1), + rtp_checksums: Number(document.getElementById('sip-rtp-checksums')?.value || 1), + rtp_keepalive: Number(document.getElementById('sip-rtp-keepalive')?.value || 15), + rtp_timeout: Number(document.getElementById('sip-rtp-timeout')?.value || 30), + allow_anonymous_sip: Number(document.getElementById('sip-allow-anonymous')?.value || 0), + local_networks: localNets + }; + + if (btn) btn.disabled = true; + if (spinner) spinner.classList.remove('hidden'); + + try { + const res = await apiCall('save_sip_settings', payload); + showToast(res.message, 'success'); + const container = document.getElementById('local-networks-container'); + if (container) delete container.dataset.initialized; + await loadState(); + } catch (err) { + showToast(err.message, 'error'); + } finally { + if (btn) btn.disabled = false; + if (spinner) spinner.classList.add('hidden'); + } + }); + + // Formulaire Édition d'un Code de Fonction + document.getElementById('form-edit-feature-code')?.addEventListener('submit', async (e) => { + e.preventDefault(); + const btn = document.getElementById('btn-submit-edit-fc'); + const spinner = document.getElementById('spinner-edit-fc'); + const id = Number(document.getElementById('edit-fc-id')?.value || 0); + const code = (document.getElementById('edit-fc-code')?.value || '').trim(); + + if (btn) btn.disabled = true; + if (spinner) spinner.classList.remove('hidden'); + + try { + const res = await apiCall('update_feature_code', { id, code }); + showToast(res.message, 'success'); + closeModal('modal-edit-feature-code'); + await loadState(); + } catch (err) { + showToast(err.message, 'error'); + } finally { + if (btn) btn.disabled = false; + if (spinner) spinner.classList.add('hidden'); + } + }); + + // Formulaire Paramètres de Sauvegarde & SSH + document.getElementById('backup-settings-form')?.addEventListener('submit', async (e) => { + e.preventDefault(); + const f = e.target; + const autoEnabled = f.elements['backup_auto_enabled']?.checked ? 1 : 0; + const frequency = f.elements['backup_frequency']?.value || 'daily'; + const retention = Number(f.elements['backup_retention']?.value || 7); + const storageType = f.querySelector('input[name="backup_storage_type"]:checked')?.value || 'local'; + const sshHost = (f.elements['backup_ssh_host']?.value || '').trim(); + const sshPort = Number(f.elements['backup_ssh_port']?.value || 22); + const sshUser = (f.elements['backup_ssh_user']?.value || '').trim(); + const sshPassword = f.elements['backup_ssh_password']?.value || ''; + const sshRemotePath = (f.elements['backup_ssh_remote_path']?.value || '/backups/nexus').trim(); + + const payload = { + backup_auto_enabled: autoEnabled, + backup_frequency: frequency, + backup_retention: retention, + backup_storage_type: storageType, + backup_ssh_host: sshHost, + backup_ssh_port: sshPort, + backup_ssh_user: sshUser, + backup_ssh_auth_type: 'password', + backup_ssh_password: sshPassword, + backup_ssh_remote_path: sshRemotePath + }; + + try { + const res = await apiCall('save_backup_settings', payload); + showToast(res.message, 'success'); + await loadBackups(); + } catch (err) { + showToast("Erreur enregistrement : " + err.message, 'error'); + } + }); + + // Formulaire Paramètres Pare-feu & Fail2ban + document.getElementById('firewall-settings-form')?.addEventListener('submit', async (e) => { + e.preventDefault(); + const f = e.target; + const btn = document.getElementById('btn-save-firewall-settings'); + if (btn) btn.disabled = true; + + const payload = { + firewall_enabled: f.elements['firewall_enabled']?.checked ? 1 : (f.elements['certbot_strict_port80']?.checked ? 1 : 0), + admin_https_port: Number(f.elements['admin_https_port']?.value || 8443), + certbot_strict_port80: f.elements['certbot_strict_port80']?.checked ? 1 : 0, + fail2ban_sip_maxretry: Number(f.elements['fail2ban_sip_maxretry']?.value || 3), + fail2ban_sip_bantime: Number(f.elements['fail2ban_sip_bantime']?.value || 3600), + fail2ban_whitelist_ips: (f.elements['fail2ban_whitelist_ips']?.value || '').trim() + }; + + try { + const res = await apiCall('save_firewall_settings', payload); + showToast(res.message, 'success'); + await loadFirewallStatus(); + } catch (err) { + showToast("Erreur application sécurité : " + err.message, 'error'); + } finally { + if (btn) btn.disabled = false; + } + }); + + // Formulaire Bannissement Manuel Rapide + document.getElementById('form-manual-ban')?.addEventListener('submit', async (e) => { + e.preventDefault(); + const ip = (document.getElementById('manual-ban-ip')?.value || '').trim(); + const jail = document.getElementById('manual-ban-jail')?.value || 'asterisk'; + if (!ip) return; + + try { + const res = await apiCall('ban_ip', { ip, jail }); + showToast(res.message, res.success ? 'success' : 'error'); + document.getElementById('manual-ban-ip').value = ''; + await loadFirewallStatus(); + } catch (err) { + showToast("Erreur lors du bannissement : " + err.message, 'error'); + } + }); + + // Si déjà authentifié au chargement initial + if (!document.getElementById('app-screen').classList.contains('hidden')) { + loadState(); + } +}); + +// ========================================================================= +// GESTIONNAIRE DE CERTIFICATS SSL / TLS (FONCTIONS GLOBALES) +// ========================================================================= +function renderCertificates() { + const certs = appState.certificates || []; + const activeCert = appState.active_certificate || certs.find(c => Number(c.is_default) === 1) || certs[0]; + + // Badge sidebar + const badgeSidebar = document.getElementById('nav-badge-certificates'); + if (badgeSidebar) { + badgeSidebar.textContent = certs.length; + } + + const badgeTable = document.getElementById('certs-count-badge'); + if (badgeTable) { + badgeTable.textContent = `${certs.length} certificat${certs.length > 1 ? 's' : ''}`; + } + + // Carte Résumé Actif + if (activeCert) { + const nameEl = document.getElementById('active-cert-name'); + const domEl = document.getElementById('active-cert-domain'); + const issEl = document.getElementById('active-cert-issuer'); + const typeEl = document.getElementById('active-cert-type-badge'); + const daysEl = document.getElementById('active-cert-days'); + const renewWrap = document.getElementById('active-cert-renew-wrapper'); + + if (nameEl) nameEl.textContent = activeCert.name || 'Certificat Actif'; + if (domEl) domEl.textContent = activeCert.primary_domain || 'pbx.local'; + if (issEl) issEl.textContent = activeCert.issuer || 'Inconnu'; + + if (typeEl) { + if (activeCert.type === 'letsencrypt') { + typeEl.className = 'px-2 py-0.5 rounded-full text-[10px] font-bold bg-emerald-500/20 text-emerald-300 border border-emerald-500/40'; + typeEl.textContent = "Let's Encrypt (ACME Auto)"; + } else if (activeCert.type === 'custom') { + typeEl.className = 'px-2 py-0.5 rounded-full text-[10px] font-bold bg-cyan-500/20 text-cyan-300 border border-cyan-500/40'; + typeEl.textContent = "Certificat Entreprise (Import)"; + } else { + typeEl.className = 'px-2 py-0.5 rounded-full text-[10px] font-bold bg-anthracite-700 text-slate-300 border border-anthracite-600'; + typeEl.textContent = "Auto-signé Système"; + } + } + + if (daysEl) { + const days = Number(activeCert.days_remaining ?? 0); + if (days < 0) { + daysEl.className = 'text-xs font-bold text-rose-400 mt-0.5'; + daysEl.textContent = `Expiré (${Math.abs(days)}j)`; + } else if (days <= 15) { + daysEl.className = 'text-xs font-bold text-amber-400 mt-0.5'; + daysEl.textContent = `${days} j restants (À renouveler)`; + } else { + daysEl.className = 'text-xs font-bold text-emerald-400 mt-0.5'; + daysEl.textContent = `${days} j restants`; + } + } + + if (renewWrap) { + if (activeCert.type === 'letsencrypt') { + renewWrap.classList.remove('hidden'); + } else { + renewWrap.classList.add('hidden'); + } + } + } + + // Statut Mutual TLS (mTLS) verify_client + const settings = appState.settings || {}; + const mtlsVal = Number(settings.tls_verify_client ?? 0); + const selMtls = document.getElementById('select-tls-verify-client'); + const badgeMtls = document.getElementById('badge-mtls-status'); + if (selMtls) { + selMtls.value = String(mtlsVal); + } + if (badgeMtls) { + if (mtlsVal === 1) { + badgeMtls.className = 'px-2 py-0.5 rounded-full text-[10px] font-bold bg-amber-500/20 text-amber-300 border border-amber-500/40'; + badgeMtls.textContent = 'Activé (Strict / mTLS)'; + } else { + badgeMtls.className = 'px-2 py-0.5 rounded-full text-[10px] font-bold bg-anthracite-700 text-slate-300 border border-anthracite-600'; + badgeMtls.textContent = 'Désactivé (Standard)'; + } + } + + // Tableau des certificats + const tbody = document.getElementById('certificates-tbody'); + if (!tbody) return; + + if (certs.length === 0) { + tbody.innerHTML = ` + + + Aucun certificat enregistré. Cliquez sur « Générer via Let's Encrypt » ou « Importer un Certificat ». + + + `; + return; + } + + tbody.innerHTML = certs.map(c => { + const isDefault = Number(c.is_default) === 1; + const days = Number(c.days_remaining ?? 0); + + let typeBadge = ''; + if (c.type === 'letsencrypt') { + typeBadge = `Let's Encrypt`; + } else if (c.type === 'custom') { + typeBadge = `Import Custom`; + } else { + typeBadge = `Auto-signé`; + } + + let validityBadge = ''; + if (days < 0) { + validityBadge = `Expiré (${c.valid_to ? c.valid_to.substring(0, 10) : ''})`; + } else if (days <= 15) { + validityBadge = `${days}j restants (${c.valid_to ? c.valid_to.substring(0, 10) : ''})`; + } else { + validityBadge = `${days}j restants (${c.valid_to ? c.valid_to.substring(0, 10) : ''})`; + } + + let defaultBadge = ''; + if (isDefault) { + defaultBadge = ` +
+ + ACTIF PAR DÉFAUT +
+ `; + } else { + defaultBadge = ` + + `; + } + + let actions = '
'; + if (c.type === 'letsencrypt') { + actions += ` + + `; + } + + if (!isDefault) { + actions += ` + + `; + } + actions += '
'; + + const sanDisplay = c.alt_names ? `
+ ${escapeHtml(c.alt_names)}
` : ''; + + return ` + + +
+ ${escapeHtml(c.name)} + ${isDefault ? '★' : ''} +
+ + ${typeBadge} + +
${escapeHtml(c.primary_domain)}
+ ${sanDisplay} + + ${escapeHtml(c.issuer || '-')} + ${validityBadge} + ${defaultBadge} + ${actions} + + `; + }).join(''); +} + +function openLetsEncryptModal() { + const f = document.getElementById('form-cert-letsencrypt'); + if (f) f.reset(); + const box = document.getElementById('letsencrypt-msg-box'); + if (box) { box.className = 'hidden'; box.textContent = ''; } + if (appState.settings && appState.settings.domain && appState.settings.domain !== 'pbx.local') { + const domInput = f?.querySelector('input[name="domain"]'); + if (domInput && !domInput.value) domInput.value = appState.settings.domain; + } + openModal('modal-cert-letsencrypt'); +} + +function openCustomCertModal() { + const f = document.getElementById('form-cert-custom'); + if (f) f.reset(); + const box = document.getElementById('custom-cert-msg-box'); + if (box) { box.className = 'hidden'; box.textContent = ''; } + openModal('modal-cert-custom'); +} + +async function setDefaultCertificate(id) { + if (!confirm("Voulez-vous activer ce certificat comme certificat par défaut ?\n\nIl sera immédiatement utilisé pour le transport PJSIP TLS (port 5061) et le chiffrement des communications audio (SRTP).")) { + return; + } + try { + const res = await apiCall('set_default_cert', { id: Number(id) }); + showToast(res.message, 'success'); + await loadState(); + } catch (err) { + showToast(err.message, 'error'); + } +} + +async function renewCertificate(id) { + if (!confirm("Lancer le renouvellement ACME Let's Encrypt pour ce certificat ?")) { + return; + } + try { + showToast("Renouvellement Let's Encrypt en cours...", 'info'); + const res = await apiCall('renew_cert', { id: Number(id) }); + showToast(res.message, 'success'); + await loadState(); + } catch (err) { + showToast("Erreur de renouvellement : " + err.message, 'error'); + } +} + +async function renewCertificateFromBanner() { + const certs = appState.certificates || []; + const activeCert = appState.active_certificate || certs.find(c => Number(c.is_default) === 1); + if (activeCert && activeCert.id) { + await renewCertificate(activeCert.id); + } +} + +async function deleteCertificate(id, name) { + if (!confirm(`Confirmez-vous la suppression du certificat « ${name} » ?\nCette action est irréversible.`)) { + return; + } + try { + const res = await apiCall('delete_cert', { id: Number(id) }); + showToast(res.message, 'success'); + await loadState(); + } catch (err) { + showToast(err.message, 'error'); + } +} + +async function saveTlsSecurityPolicy() { + const sel = document.getElementById('select-tls-verify-client'); + const val = Number(sel?.value ?? 0); + const label = val === 1 + ? 'Activer le mode Mutual TLS strict (certificat client X.509 obligatoirement exigé sur chaque téléphone)' + : 'Désactiver le mode Mutual TLS (standard : authentification par mot de passe SIP dans le tunnel TLS)'; + if (!confirm(`Confirmez-vous cette modification de la politique TLS ?\n\n${label}\n\nAsterisk rechargera immédiatement le transport TLS 5061.`)) { + return; + } + try { + const res = await apiCall('set_tls_security', { tls_verify_client: val }); + showToast(res.message, 'success'); + await loadState(); + } catch (err) { + showToast(err.message, 'error'); + } +} + +// ========================================================================= +// PARAMÈTRES SIP GLOBAUX & NAT (FONCTIONS GLOBALES) +// ========================================================================= +function renderSipSettings() { + const s = appState.settings || {}; + + const extIpEl = document.getElementById('sip-external-ip'); + if (extIpEl && document.activeElement !== extIpEl) { + extIpEl.value = s.external_ip || ''; + } + + const extUdpPortEl = document.getElementById('sip-external-udp-port'); + if (extUdpPortEl && document.activeElement !== extUdpPortEl) { + extUdpPortEl.value = (s.external_udp_port && Number(s.external_udp_port) > 0) ? s.external_udp_port : ''; + } + + const extTlsPortEl = document.getElementById('sip-external-tls-port'); + if (extTlsPortEl && document.activeElement !== extTlsPortEl) { + extTlsPortEl.value = (s.external_tls_port && Number(s.external_tls_port) > 0) ? s.external_tls_port : ''; + } + + const udpPortEl = document.getElementById('sip-udp-port'); + if (udpPortEl && document.activeElement !== udpPortEl) { + udpPortEl.value = s.udp_port || 5060; + } + + const tlsPortEl = document.getElementById('sip-tls-port'); + if (tlsPortEl && document.activeElement !== tlsPortEl) { + tlsPortEl.value = s.tls_port || 5061; + } + + const rtpStartEl = document.getElementById('sip-rtp-start'); + if (rtpStartEl && document.activeElement !== rtpStartEl) { + rtpStartEl.value = s.rtp_start || 10000; + } + + const rtpEndEl = document.getElementById('sip-rtp-end'); + if (rtpEndEl && document.activeElement !== rtpEndEl) { + rtpEndEl.value = s.rtp_end || 20000; + } + + const rtpStrictEl = document.getElementById('sip-rtp-strict'); + if (rtpStrictEl) { + rtpStrictEl.value = (s.rtp_strict !== undefined && s.rtp_strict !== null) ? String(s.rtp_strict) : '1'; + } + + const rtpChecksumsEl = document.getElementById('sip-rtp-checksums'); + if (rtpChecksumsEl) { + rtpChecksumsEl.value = (s.rtp_checksums !== undefined && s.rtp_checksums !== null) ? String(s.rtp_checksums) : '1'; + } + + const rtpKeepaliveEl = document.getElementById('sip-rtp-keepalive'); + if (rtpKeepaliveEl && document.activeElement !== rtpKeepaliveEl) { + rtpKeepaliveEl.value = (s.rtp_keepalive !== undefined && s.rtp_keepalive !== null) ? s.rtp_keepalive : 15; + } + + const rtpTimeoutEl = document.getElementById('sip-rtp-timeout'); + if (rtpTimeoutEl && document.activeElement !== rtpTimeoutEl) { + rtpTimeoutEl.value = (s.rtp_timeout !== undefined && s.rtp_timeout !== null) ? s.rtp_timeout : 30; + } + + const allowAnonEl = document.getElementById('sip-allow-anonymous'); + if (allowAnonEl) { + allowAnonEl.value = (s.allow_anonymous_sip !== undefined && s.allow_anonymous_sip !== null) ? String(s.allow_anonymous_sip) : '0'; + } + + // Réseaux locaux (Local Networks list) + const container = document.getElementById('local-networks-container'); + if (container && !container.dataset.initialized) { + container.innerHTML = ''; + const nets = Array.isArray(s.local_networks) && s.local_networks.length > 0 + ? s.local_networks + : (s.local_net ? [s.local_net] : ['10.10.0.0/16']); + + nets.forEach(net => addLocalNetworkField(net)); + container.dataset.initialized = 'true'; + } +} + +function addLocalNetworkField(value = '') { + const container = document.getElementById('local-networks-container'); + if (!container) return; + + const row = document.createElement('div'); + row.className = 'flex items-center gap-2 local-network-row'; + row.innerHTML = ` +
+ +
+ + `; + container.appendChild(row); +} + +function removeLocalNetworkField(btn) { + const container = document.getElementById('local-networks-container'); + if (!container) return; + const rows = container.querySelectorAll('.local-network-row'); + if (rows.length <= 1) { + showToast("Au moins un réseau local doit être défini.", "error"); + return; + } + btn.closest('.local-network-row')?.remove(); +} + +async function detectPublicIp() { + const btn = document.getElementById('btn-detect-ip'); + const spinner = document.getElementById('spinner-detect-ip'); + const input = document.getElementById('sip-external-ip'); + + if (btn) btn.disabled = true; + if (spinner) spinner.classList.remove('hidden'); + + try { + const res = await apiCall('detect_external_ip'); + if (res.ip && input) { + input.value = res.ip; + showToast(`IP publique détectée avec succès : ${res.ip}`, 'success'); + } else { + showToast("Impossible de déterminer l'adresse IP publique automatiquement.", "error"); + } + } catch (err) { + showToast("Erreur de détection : " + err.message, "error"); + } finally { + if (btn) btn.disabled = false; + if (spinner) spinner.classList.add('hidden'); + } +} + +// ========================================================================= +// CODES DE FONCTIONS TÉLÉPHONIQUES & SERVICES (FONCTIONS GLOBALES) +// ========================================================================= +let currentFeatureCategory = 'all'; + +function filterFeatureCodes(category) { + currentFeatureCategory = category; + document.querySelectorAll('.fc-filter-btn').forEach(btn => { + if (btn.dataset.category === category) { + btn.className = 'fc-filter-btn px-3 py-1.5 rounded-xl text-xs font-semibold bg-indigo-600 text-white transition'; + } else { + btn.className = 'fc-filter-btn px-3 py-1.5 rounded-xl text-xs font-semibold bg-anthracite-800 text-slate-300 hover:text-white transition'; + } + }); + renderFeatureCodes(); +} + +function renderFeatureCodes() { + const list = appState.feature_codes || []; + const tbody = document.getElementById('feature-codes-table-body'); + const badge = document.getElementById('nav-badge-featurecodes'); + const countAll = document.getElementById('fc-count-all'); + + const activeCount = list.filter(f => Number(f.enabled) === 1).length; + if (badge) badge.textContent = String(activeCount); + if (countAll) countAll.textContent = String(list.length); + + if (!tbody) return; + + const filtered = (currentFeatureCategory === 'all') + ? list + : list.filter(item => item.category === currentFeatureCategory); + + if (filtered.length === 0) { + tbody.innerHTML = ` + + + Aucun code de fonction trouvé dans cette catégorie. + + + `; + return; + } + + const categoryBadges = { + forward: 'Renvois', + pickup: 'BLF / Capture', + dnd: 'Ne Pas Déranger', + voicemail: 'Messagerie', + security: 'Sécurité', + tools: 'Outils' + }; + + tbody.innerHTML = filtered.map(fc => { + const isEnabled = Number(fc.enabled) === 1; + const catBadge = categoryBadges[fc.category] || `${fc.category}`; + const codeDisplay = escapeHtml(fc.code); + + return ` + + + ${codeDisplay} + + +
${escapeHtml(fc.name)}
+
Standard : ${escapeHtml(fc.default_code)}
+ + + ${escapeHtml(fc.description)} + + + ${catBadge} + + + + + + + + + `; + }).join(''); +} + +async function toggleFeatureCode(id, enabled) { + try { + const res = await apiCall('toggle_feature_code', { id: Number(id), enabled: enabled ? 1 : 0 }); + showToast(res.message, 'success'); + await loadState(); + } catch (err) { + showToast(err.message, 'error'); + await loadState(); + } +} + +function openEditFeatureCodeModal(id) { + const list = appState.feature_codes || []; + const item = list.find(f => Number(f.id) === Number(id)); + if (!item) return; + + document.getElementById('edit-fc-id').value = item.id; + document.getElementById('edit-fc-code').value = item.code; + document.getElementById('modal-fc-service-name').textContent = item.name; + document.getElementById('modal-fc-default-code').textContent = item.default_code; + + openModal('modal-edit-feature-code'); +} + +async function resetFeatureCodes() { + if (!confirm("Voulez-vous réinitialiser tous les codes de fonctions aux valeurs standard FreePBX / Asterisk ?\n\n(ex: ** pour BLF, *8 pour groupe, *72/*73 pour renvois, *78/*79 pour DND, *97 pour boîte vocale)")) { + return; + } + try { + const res = await apiCall('reset_feature_codes'); + showToast(res.message, 'success'); + await loadState(); + } catch (err) { + showToast(err.message, 'error'); + } +} + +// ========================================================================= +// GESTIONNAIRE DE SAUVEGARDES & RESTAURATION (DISASTER RECOVERY) +// ========================================================================= + +let appBackupsState = { + settings: {}, + backups: [] +}; + +async function loadBackups() { + try { + const res = await apiCall('get_backup_data', {}); + if (res.success) { + appBackupsState.settings = res.settings || {}; + appBackupsState.backups = res.backups || []; + renderBackups(); + } + } catch (err) { + showToast("Erreur chargement des sauvegardes : " + err.message, 'error'); + } +} + +function renderBackups() { + const s = appBackupsState.settings || {}; + const backups = appBackupsState.backups || []; + + // 1. Stats d'en-tête + const lastRunEl = document.getElementById('backup-stat-last-run'); + if (lastRunEl) { + lastRunEl.textContent = s.backup_last_run ? s.backup_last_run : 'Jamais exécutée'; + } + + const storageEl = document.getElementById('backup-stat-storage'); + if (storageEl) { + if (s.backup_storage_type === 'ssh') { + storageEl.textContent = `Distant SSH (${s.backup_ssh_host || 'non configuré'})`; + storageEl.className = 'text-sm font-bold text-purple-400 truncate'; + } else { + storageEl.textContent = 'Stockage Local'; + storageEl.className = 'text-sm font-bold text-emerald-400 truncate'; + } + } + + const countEl = document.getElementById('backup-stat-count'); + if (countEl) { + countEl.textContent = `${backups.length} archive(s)`; + } + + const badgeSidebar = document.getElementById('nav-badge-backups'); + if (badgeSidebar) { + badgeSidebar.textContent = backups.length; + } + + // 2. Remplissage du formulaire de configuration + const form = document.getElementById('backup-settings-form'); + if (form) { + if (form.elements['backup_auto_enabled']) { + form.elements['backup_auto_enabled'].checked = Number(s.backup_auto_enabled) === 1; + } + if (form.elements['backup_frequency']) { + form.elements['backup_frequency'].value = s.backup_frequency || 'daily'; + } + if (form.elements['backup_retention']) { + form.elements['backup_retention'].value = s.backup_retention || 7; + } + const storageRadios = form.querySelectorAll('input[name="backup_storage_type"]'); + storageRadios.forEach(r => { + r.checked = (r.value === (s.backup_storage_type || 'local')); + }); + if (form.elements['backup_ssh_host']) { + form.elements['backup_ssh_host'].value = s.backup_ssh_host || ''; + } + if (form.elements['backup_ssh_port']) { + form.elements['backup_ssh_port'].value = s.backup_ssh_port || 22; + } + if (form.elements['backup_ssh_user']) { + form.elements['backup_ssh_user'].value = s.backup_ssh_user || ''; + } + if (form.elements['backup_ssh_password']) { + form.elements['backup_ssh_password'].value = s.backup_ssh_password || ''; + } + if (form.elements['backup_ssh_remote_path']) { + form.elements['backup_ssh_remote_path'].value = s.backup_ssh_remote_path || '/backups/nexus'; + } + + toggleSshBackupFields((s.backup_storage_type || 'local') === 'ssh'); + } + + // 3. Remplissage du tableau des archives + const tbody = document.getElementById('backups-table-body'); + if (!tbody) return; + + if (backups.length === 0) { + tbody.innerHTML = ` + + +
+ + + + Aucune archive de sauvegarde disponible. Cliquez sur "Créer une Sauvegarde Immédiate" pour générer la première. +
+ + + `; + return; + } + + tbody.innerHTML = backups.map(b => { + const isLocal = b.is_local; + const locBadge = isLocal + ? `Local` + : `Distant (SSH)`; + + const versionBadge = `v${escapeHtml(b.version || '1.2.0')}`; + + return ` + + +
+ + + + ${escapeHtml(b.filename)} + ${versionBadge} +
+ + ${escapeHtml(b.date_formatted)} + ${escapeHtml(b.size_formatted)} + ${locBadge} + +
+ + + +
+ + + `; + }).join(''); +} + +function toggleSshBackupFields(show) { + const fields = document.getElementById('backup-ssh-fields'); + if (!fields) return; + if (show) { + fields.classList.remove('hidden'); + } else { + fields.classList.add('hidden'); + } +} + +async function createBackupNow() { + const btn = document.getElementById('btn-create-backup-now'); + const label = document.getElementById('btn-create-backup-label'); + if (btn) btn.disabled = true; + if (label) label.textContent = 'Génération de l\'archive...'; + + try { + const res = await apiCall('create_backup', {}); + showToast(res.message, 'success'); + await loadBackups(); + } catch (err) { + showToast("Échec de création : " + err.message, 'error'); + } finally { + if (btn) btn.disabled = false; + if (label) label.textContent = 'Créer une Sauvegarde Immédiate'; + } +} + +async function confirmRestoreBackup(filename) { + const ok = confirm(`⚠️ ATTENTION — RESTAURATION COMPLÈTE DU SYSTÈME NEXUSPBX ⚠️\n\nVoulez-vous restaurer le système depuis l'archive :\n${filename} ?\n\nCette action va :\n1. Remplacer la base de données SQLite\n2. Restaurer tous les fichiers sons, annonces et musiques\n3. Restaurer toutes les boîtes vocales, salutations et PINs\n4. Recharger l'intégralité du PBX Asterisk\n\nConfirmez-vous la restauration immédiate ?`); + if (!ok) return; + + showToast("Restauration du système en cours... Veuillez patienter.", "info"); + + try { + const res = await apiCall('restore_backup', { filename }); + showToast(res.message || "Restauration terminée avec succès !", 'success'); + setTimeout(() => { + window.location.reload(); + }, 2000); + } catch (err) { + showToast("Échec de la restauration : " + err.message, 'error'); + } +} + +function triggerBackupUpload() { + document.getElementById('backup-upload-input')?.click(); +} + +async function handleBackupUpload(input) { + const file = input?.files?.[0]; + if (!file) return; + + const ok = confirm(`⚠️ RESTAURATION DISASTER RECOVERY VIA FICHIER TÉLÉVERSÉ ⚠️\n\nFichier sélectionné : ${file.name} (${(file.size / (1024 * 1024)).toFixed(2)} Mo)\n\nVoulez-vous restaurer immédiatement le système NexusPBX avec cette archive ?\nToutes les données actuelles seront remplacées.`); + if (!ok) { + input.value = ''; + return; + } + + showToast("Téléversement et restauration de l'archive en cours...", "info"); + + const fd = new FormData(); + fd.append('backup_file', file); + + try { + const response = await fetch('/api.php?action=upload_restore_backup', { + method: 'POST', + body: fd + }); + const res = await response.json(); + if (!res.success) { + throw new Error(res.message || "Erreur lors de la restauration"); + } + showToast(res.message, 'success'); + setTimeout(() => { + window.location.reload(); + }, 2000); + } catch (err) { + showToast("Échec de la restauration : " + err.message, 'error'); + } finally { + input.value = ''; + } +} + +function downloadBackup(filename) { + window.location.href = `/api.php?action=download_backup&filename=${encodeURIComponent(filename)}`; +} + +async function deleteBackup(filename) { + if (!confirm(`Êtes-vous sûr de vouloir supprimer définitivement l'archive de sauvegarde suivante ?\n\n${filename}`)) { + return; + } + + try { + const res = await apiCall('delete_backup', { filename }); + showToast(res.message, res.success ? 'success' : 'error'); + await loadBackups(); + } catch (err) { + showToast("Erreur lors de la suppression : " + err.message, 'error'); + } +} + +async function testSshBackupConnection() { + const form = document.getElementById('backup-settings-form'); + if (!form) return; + + const payload = { + backup_ssh_host: (form.elements['backup_ssh_host']?.value || '').trim(), + backup_ssh_port: Number(form.elements['backup_ssh_port']?.value || 22), + backup_ssh_user: (form.elements['backup_ssh_user']?.value || '').trim(), + backup_ssh_password: form.elements['backup_ssh_password']?.value || '', + backup_ssh_remote_path: (form.elements['backup_ssh_remote_path']?.value || '/backups/nexus').trim(), + backup_ssh_auth_type: 'password' + }; + + showToast("Test de la connexion SSH vers " + payload.backup_ssh_host + "...", "info"); + + try { + const res = await apiCall('test_ssh_backup', payload); + if (res.success) { + showToast(res.message, 'success'); + } else { + showToast(res.message, 'error'); + } + } catch (err) { + showToast("Échec du test SSH : " + err.message, 'error'); + } +} + +// ========================================================================= +// GESTIONNAIRE DE PARE-FEU & SÉCURITÉ (FAIL2BAN & NFTABLES) +// ========================================================================= + +let appFirewallState = { + is_running: false, + settings: {}, + jails: {}, + banned_ips: [], + total_banned: 0, + client_ip: '' +}; + +async function loadFirewallStatus() { + try { + const res = await apiCall('get_firewall_status', {}); + if (res.success && res.data) { + appFirewallState = res.data; + renderFirewall(); + } + } catch (err) { + showToast("Erreur chargement sécurité : " + err.message, 'error'); + } +} + +function renderFirewall() { + const d = appFirewallState; + const s = d.settings || {}; + const banned = d.banned_ips || []; + + // 1. Stat cards + const engineEl = document.getElementById('fw-stat-engine'); + if (engineEl) { + engineEl.textContent = d.is_running ? 'En ligne (Actif)' : 'Arrêté (Inactif)'; + engineEl.className = d.is_running ? 'text-sm font-bold text-emerald-400 truncate' : 'text-sm font-bold text-rose-400 truncate'; + } + + const sipJail = d.jails?.asterisk; + const sipJailEl = document.getElementById('fw-stat-sip-jail'); + if (sipJailEl) { + sipJailEl.textContent = sipJail ? `Actif (${sipJail.banned_ips?.length || 0} banni)` : 'Inactif'; + } + + const webJail = d.jails?.['nexus-web']; + const webJailEl = document.getElementById('fw-stat-web-jail'); + if (webJailEl) { + webJailEl.textContent = webJail ? `Protégé (${webJail.banned_ips?.length || 0} banni)` : 'Inactif'; + } + + const countEl = document.getElementById('fw-stat-banned-count'); + if (countEl) { + countEl.textContent = `${d.total_banned} bannie(s)`; + countEl.className = d.total_banned > 0 ? 'text-sm font-bold text-rose-400 truncate' : 'text-sm font-bold text-emerald-400 truncate'; + } + + const badgeSidebar = document.getElementById('nav-badge-firewall'); + if (badgeSidebar) { + badgeSidebar.textContent = d.total_banned; + badgeSidebar.className = d.total_banned > 0 + ? 'ml-2 shrink-0 px-1.5 py-0.2 text-[10px] rounded-full bg-rose-500/20 text-rose-300 border border-rose-500/30 font-bold' + : 'ml-2 shrink-0 px-1.5 py-0.2 text-[10px] rounded-full bg-anthracite-800 text-emerald-300 border border-anthracite-700 font-bold'; + } + + // 2. Table of banned IPs + const tbody = document.getElementById('firewall-banned-table-body'); + if (tbody) { + if (banned.length === 0) { + tbody.innerHTML = ` + + +
+ + + + Aucune adresse IP n'est actuellement bloquée. + Le pare-feu NFTables et Fail2ban surveillent les flux SIP et Web 24h/24. +
+ + + `; + } else { + tbody.innerHTML = banned.map(b => { + const jailClass = b.jail === 'asterisk' ? 'bg-cyan-500/20 text-cyan-300 border border-cyan-500/30' : 'bg-purple-500/20 text-purple-300 border border-purple-500/30'; + return ` + + + ⛔ ${escapeHtml(b.ip)} + + + ${escapeHtml(b.jail_label || b.jail)} + + + DROP (Bloqué) + + + + + + `; + }).join(''); + } + } + + // 3. Form fields + const form = document.getElementById('firewall-settings-form'); + if (form) { + if (form.elements['admin_https_port']) { + form.elements['admin_https_port'].value = s.admin_https_port || 8443; + } + if (form.elements['certbot_strict_port80']) { + form.elements['certbot_strict_port80'].checked = Number(s.certbot_strict_port80) === 1; + } + if (form.elements['firewall_enabled']) { + form.elements['firewall_enabled'].checked = (Number(s.firewall_enabled) === 1 || Number(s.certbot_strict_port80) === 1); + } + if (form.elements['fail2ban_sip_maxretry']) { + form.elements['fail2ban_sip_maxretry'].value = s.fail2ban_sip_maxretry || 3; + } + if (form.elements['fail2ban_sip_bantime']) { + form.elements['fail2ban_sip_bantime'].value = s.fail2ban_sip_bantime || 3600; + } + if (form.elements['fail2ban_whitelist_ips'] && document.activeElement !== form.elements['fail2ban_whitelist_ips']) { + form.elements['fail2ban_whitelist_ips'].value = s.fail2ban_whitelist_ips || ''; + } + } +} + +async function unbanIp(ip, jail) { + if (!confirm(`Débannir l'adresse IP ${ip} et rétablir son accès immédiat ?`)) { + return; + } + + try { + const res = await apiCall('unban_ip', { ip, jail }); + showToast(res.message, res.success ? 'success' : 'error'); + await loadFirewallStatus(); + } catch (err) { + showToast("Erreur lors du débannissement : " + err.message, 'error'); + } +} + +function whitelistMyIp() { + const clientIp = appFirewallState.client_ip; + if (!clientIp) { + showToast("Impossible de déterminer votre adresse IP actuelle.", 'error'); + return; + } + + const textarea = document.getElementById('fail2ban_whitelist_ips'); + if (!textarea) return; + + const currentVal = textarea.value.trim(); + const tokens = currentVal ? currentVal.split(/\s+/) : []; + + if (tokens.includes(clientIp)) { + showToast(`Votre IP actuelle (${clientIp}) est déjà dans la liste blanche.`, 'info'); + return; + } + + tokens.push(clientIp); + textarea.value = tokens.join(' '); + showToast(`Votre IP actuelle (${clientIp}) a été ajoutée. Pensez à cliquer sur "Enregistrer" pour appliquer.`, 'success'); +} + +// Modal Journal des Événements de Sécurité +async function openSecurityEventsModal() { + const modal = document.getElementById('modal-security-events'); + const tbody = document.getElementById('security-events-tbody'); + const emptyState = document.getElementById('security-events-empty'); + if (!modal) return; + + modal.classList.remove('hidden'); + if (tbody) tbody.innerHTML = 'Chargement des événements de sécurité en direct...'; + if (emptyState) emptyState.classList.add('hidden'); + + try { + const resp = await fetch('/api.php?action=get_security_events'); + const data = await resp.json(); + const events = data.events || []; + + if (events.length === 0) { + if (tbody) tbody.innerHTML = ''; + if (emptyState) emptyState.classList.remove('hidden'); + return; + } + + if (tbody) { + tbody.innerHTML = events.map(e => ` + + ${escapeHtml(e.time)} + + + ${escapeHtml(e.service)} + + + + ${escapeHtml(e.event)} +
Échec mot de passe / Défi SIP
+ + ${escapeHtml(e.account)} + ${escapeHtml(e.ip)} + + `).join(''); + } + } catch (err) { + if (tbody) { + tbody.innerHTML = `Erreur : ${escapeHtml(err.message)}`; + } + } +} + +async function clearSecurityLog() { + if (!confirm("Voulez-vous vraiment vider le journal des événements de sécurité et réinitialiser les compteurs d'attaques à zéro ?")) { + return; + } + try { + const res = await apiCall('clear_security_log', {}); + showToast(res.message, 'success'); + await openSecurityEventsModal(); + await pollMetrics(); + } catch (err) { + showToast("Erreur lors de la purge du journal : " + err.message, 'error'); + } +} + +// ========================================================================= +// 2C. ANNUAIRE D'ENTREPRISE & YEALINK REMOTE PHONEBOOK XML +// ========================================================================= +let selectedPhonebookGroup = 'all'; + +function renderPhonebookTable() { + const tbody = document.getElementById('phonebook-tbody'); + if (!tbody) return; + + const contacts = appState.phonebook_contacts || []; + const groups = appState.phonebook_groups || []; + const searchVal = (document.getElementById('phonebook-search')?.value || '').trim().toLowerCase(); + + // 1. Rendu des Group Pills + const pillsContainer = document.getElementById('phonebook-group-pills'); + if (pillsContainer) { + let pillsHtml = ` + + `; + + groups.forEach(g => { + const count = contacts.filter(c => { + const cg = (c.group_name || '').toLowerCase(); + const gl = g.toLowerCase(); + return cg === gl || cg.includes(gl) || cg === 'tous' || cg === 'all'; + }).length; + const isActive = selectedPhonebookGroup.toLowerCase() === g.toLowerCase(); + pillsHtml += ` + + `; + }); + pillsContainer.innerHTML = pillsHtml; + } + + // 2. Filtrage des contacts + const filtered = contacts.filter(c => { + // Filtre de groupe + if (selectedPhonebookGroup !== 'all') { + const cg = (c.group_name || '').toLowerCase(); + const sg = selectedPhonebookGroup.toLowerCase(); + if (cg !== sg && !cg.includes(sg) && cg !== 'tous' && cg !== 'all') { + return false; + } + } + // Filtre de recherche + if (searchVal !== '') { + const matchName = (c.display_name || '').toLowerCase().includes(searchVal); + const matchInt = (c.internal_number || '').toLowerCase().includes(searchVal); + const matchMob = (c.mobile_number || '').toLowerCase().includes(searchVal); + const matchOth = (c.other_number || '').toLowerCase().includes(searchVal); + const matchMail = (c.email || '').toLowerCase().includes(searchVal); + const matchGrp = (c.group_name || '').toLowerCase().includes(searchVal); + if (!matchName && !matchInt && !matchMob && !matchOth && !matchMail && !matchGrp) { + return false; + } + } + return true; + }); + + // 3. Statistiques affichées + const statsEl = document.getElementById('phonebook-stats'); + if (statsEl) { + const intCount = contacts.filter(c => c.is_internal).length; + const extCount = contacts.length - intCount; + statsEl.innerHTML = `${filtered.length} affiché(s) • ${intCount} interne(s) • ${extCount} externe(s)`; + } + + if (filtered.length === 0) { + tbody.innerHTML = `Aucun contact trouvé dans l'annuaire pour ces critères.`; + return; + } + + tbody.innerHTML = filtered.map(c => { + const initials = getInitials(c.display_name || '?'); + const colorClass = getAvatarBgColor(c.display_name || ''); + const avatarHtml = c.photo_url + ? `${escapeHtml(c.display_name)}` + : `
${escapeHtml(initials)}
`; + + const typeBadge = c.is_internal + ? `Poste ${escapeHtml(c.internal_number)}` + : `Contact Externe`; + + return ` + + ${avatarHtml} + +
${escapeHtml(c.display_name)}
+ ${c.photo_url ? 'Photo active' : ''} + + ${c.internal_number ? escapeHtml(c.internal_number) : '—'} + ${c.mobile_number ? escapeHtml(c.mobile_number) : '—'} + ${c.other_number ? escapeHtml(c.other_number) : '—'} + ${c.email ? `${escapeHtml(c.email)}` : '—'} + + ${escapeHtml(c.group_name || 'Général')} + + ${typeBadge} + + + ${!c.is_internal ? `` : ''} + + + `; + }).join(''); +} + +function getInitials(name) { + const parts = name.trim().split(/\s+/); + if (parts.length >= 2) { + return (parts[0][0] + parts[1][0]).toUpperCase(); + } + return (name.substring(0, 2) || '?').toUpperCase(); +} + +function getAvatarBgColor(str) { + const colors = [ + 'bg-teal-600', 'bg-indigo-600', 'bg-emerald-600', 'bg-cyan-600', + 'bg-purple-600', 'bg-amber-600', 'bg-rose-600', 'bg-blue-600' + ]; + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = str.charCodeAt(i) + ((hash << 5) - hash); + } + return colors[Math.abs(hash) % colors.length]; +} + +function filterPhonebook() { + renderPhonebookTable(); +} + +function selectPhonebookGroupFilter(grp) { + selectedPhonebookGroup = grp; + renderPhonebookTable(); +} + +function openPhonebookContactModal(contact = null) { + const form = document.getElementById('form-phonebook-contact'); + if (!form) return; + form.reset(); + + const titleEl = document.getElementById('modal-phonebook-title'); + if (titleEl) { + titleEl.textContent = contact ? `Modifier le Contact ${contact.display_name}` : 'Nouveau Contact Annuaire'; + } + + form.elements['id'].value = contact ? contact.id : ''; + form.elements['extension_id'].value = contact ? (contact.extension_id || '') : ''; + form.elements['is_internal'].value = contact ? (contact.is_internal ? 1 : 0) : 0; + form.elements['photo_url'].value = contact ? (contact.photo_url || '') : ''; + form.elements['display_name'].value = contact ? contact.display_name : ''; + form.elements['internal_number'].value = contact ? (contact.internal_number || '') : ''; + form.elements['mobile_number'].value = contact ? (contact.mobile_number || '') : ''; + form.elements['other_number'].value = contact ? (contact.other_number || '') : ''; + form.elements['email'].value = contact ? (contact.email || '') : ''; + form.elements['group_name'].value = contact ? (contact.group_name || 'Général') : (selectedPhonebookGroup !== 'all' ? selectedPhonebookGroup : 'Général'); + + // Datalist des groupes existants + const dl = document.getElementById('phonebook-groups-datalist'); + if (dl) { + dl.innerHTML = (appState.phonebook_groups || []).map(g => `` + + groups.map(g => ``).join(''); + if (selectedPhonebookGroup !== 'all' && groups.includes(selectedPhonebookGroup)) { + sel.value = selectedPhonebookGroup; + } + } + updateYealinkXmlUrls(); + openModal('modal-yealink-xml'); +} + +function updateYealinkXmlUrls() { + const sel = document.getElementById('yealink-modal-group-select'); + const grp = sel ? sel.value : 'all'; + const host = window.location.hostname || '10.10.175.204'; + const httpsPort = window.location.port || '8443'; + const groupParam = grp !== 'all' ? `?group=${encodeURIComponent(grp)}` : `?group=all`; + + const url8080 = `http://${host}:8080/phonebook.php${groupParam}`; + const urlHttps = `https://${host}:${httpsPort}/phonebook.php${groupParam}`; + + const el8080 = document.getElementById('yealink-url-8080'); + if (el8080) el8080.value = url8080; + const elHttps = document.getElementById('yealink-url-https'); + if (elHttps) elHttps.value = urlHttps; + + const openBtn = document.getElementById('yealink-open-browser-btn'); + if (openBtn) openBtn.href = url8080; + + const previewEl = document.getElementById('yealink-xml-preview'); + if (previewEl) { + previewEl.textContent = 'Chargement du flux XML...'; + fetch(`/phonebook.php${groupParam}`) + .then(r => r.text()) + .then(xml => { + previewEl.textContent = xml; + }) + .catch(() => { + previewEl.textContent = `\n\n Annuaire ${grp}\n`; + }); + } +} + +function copyYealinkUrl(inputId, btn) { + const el = document.getElementById(inputId); + if (!el) return; + el.select(); + navigator.clipboard.writeText(el.value).then(() => { + showToast("URL Yealink copiée dans le presse-papiers !", 'success'); + const origText = btn.innerHTML; + btn.innerHTML = 'Copié !'; + setTimeout(() => { btn.innerHTML = origText; }, 2000); + }).catch(() => { + showToast("Sélectionnez l'URL et appuyez sur Ctrl+C.", 'info'); + }); +} diff --git a/public/avatars/.gitkeep b/public/avatars/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..9930465 --- /dev/null +++ b/public/index.php @@ -0,0 +1,4398 @@ + + + + + + + NexusPBX — Supervision & Gestion PJSIP sur Debian + + + + + + + +
+ + +
+
+
+
+ + + +
+
+

NexusPBX

+

Supervision & Gestion PJSIP • Debian 13

+
+
+ + + + +
+
+ + +
+
+ + +
+ +
+ +
+ Accès initial par défaut : + admin / admin123 +
+
+
+ + +
+ + + + + + + + +
+ + +
+
+ + +
+

Tableau de Bord & Supervision Temps Réel

+ +
+
+ + +
+ + + + + +
+
+ + +
+ + + + +
+ + +
+ + +
+
+
+ Processeur (CPU) + + + + + +
+
+
0.0%
+
— cœurs
+
+
Debian Linux
+
+
+
+
+
+
+ Load Average : + 0.00 / 0.00 / 0.00 +
+
+
+ + +
+
+
+ Mémoire Vive (RAM) + + + + + +
+
+
0.0%
+
0 / 0 Mo
+
+
Disponible : — Mo
+
+
+
+
+
+
+ Swap Debian : + 0 / 0 Mo +
+
+
+ + +
+
+
+ Réseau SIP & RTP + + + + + +
+
+
+
↓ RX (Entrant)
+
0.0 Ko/s
+
+
+
↑ TX (Sortant)
+
0.0 Ko/s
+
+
+
+
+ ens18 + Total : ↓ 0 Mo • ↑ 0 Mo +
+
+ + +
+
+
+ Stockage Disque (HDD) + + + + + +
+
+
0.0%
+
0 / 0 Go
+
+
Espace libre sur / : — Go
+
+
+
+
+
+
+ Base SQLite : + /var/www/nexus-pbx +
+
+
+
+ + +
+ + +
+
+
+
+ + + + + +
+

Sécurité & Protection Active

+

Pare-feu Nginx & Moteur Fail2ban

+
+
+ +
+ +
+ +
+
+ Pare-feu Strict + +
+
Inactif
+
Filtrage strict non actif
+
+ + +
+
+ Fail2ban IDS + +
+
Actif
+
3 prisons actives
+
+
+ + +
+
+ Adresses IP bannies +
+ 0 + actuellement +
+
+
+ +
Aucun ban actif
+
+
+
+ +
+ + + Protection intrusion + + IP client : 127.0.0.1 +
+
+ + +
+
+
+
+

Répartition des Menaces (Jails)

+

Tentatives d'intrusion interceptées par service

+
+ IDS / IPS +
+ + +
+ +
+
+ + + Téléphonie SIP (PJSIP) + + 0 échecs • 0 ban +
+
+
+
+
+ + +
+
+ + + Portail Web & API Admin + + 0 échecs • 0 ban +
+
+
+
+
+ + +
+
+ + + Console Système SSH + + 0 échecs • 0 ban +
+
+
+
+
+
+
+ +
+ /var/log/asterisk/security.log + +
+
+ + +
+
+
+
+

Statut des Services Système

+

Surveillance temps réel des démons Debian

+
+ TOUS OPÉRATIONNELS +
+ +
+ +
+
+ +
+ Moteur PBX : Asterisk 22 + Distro : Debian Linux +
+
+ +
+ + +
+ + +
+
+
+

Historique Charge CPU & RAM (%)

+

Actualisation automatique toutes les 3 secondes

+
+
+ CPU + RAM +
+
+
+ +
+
+ + +
+
+
+

Débit Réseau Temps Réel (Ko/s)

+

Flux SIP (5060/5061) & Flux Audio RTP

+
+
+ RX + TX +
+
+
+ +
+
+ + +
+
+

Synthèse Ressources & Téléphonie

+

État global du serveur Debian & d'Asterisk 22

+
+ +
+ +
+ + + + + 0% + Charge CPU +
+ + +
+ + + + + 0% + Mémoire RAM +
+ + +
+ + + + + 0% + Disque HDD +
+
+ +
+
+ Appels en cours : + 0 +
+
+ Postes enregistrés : + 0 +
+
+
+
+ + +
+ + +
+
+
+

État des Téléphones SIP en Temps Réel (PJSIP Contacts)

+

Visualisez instantanément quels téléphones sont enregistrés sur Asterisk et leur latence RTT.

+
+ +
+
+ + + + + + + + + + + + +
PosteNomTransport & Sécurité (Réel)Statut SIP Temps RéelIP & Équipement DétectéLatence RTT
+
+
+ + +
+
+

Architecture Téléphonique Active

+

Vue rapide des objets configurés dans SQLite.

+ +
+
+
Postes SIP
+
0
+
0 UDP • 0 TLS
+
+
+
Groupes d'appel
+
0
+
Ring Groups
+
+
+
Trunks SIP
+
0
+
Opérateurs VoIP
+
+
+
Menus SVI / IVR
+
0
+
Serveurs vocaux
+
+
+
+ +
+
+ Codes de test audio intégrés + OK +
+
+ Test d'écho interactif : + *43 +
+
+ Horloge parlante : + *60 +
+
+ Tester le SVI d'accueil : + *801 +
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/phonebook.php b/public/phonebook.php new file mode 100644 index 0000000..5038539 --- /dev/null +++ b/public/phonebook.php @@ -0,0 +1,43 @@ +:8080/phonebook.php?group=Issy-Les-Moulineaux + * http://:8080/phonebook.php?group=all + */ + +require_once __DIR__ . '/../src/Database.php'; +require_once __DIR__ . '/../src/PhonebookManager.php'; + +header('Content-Type: text/xml; charset=utf-8'); +header('Cache-Control: no-cache, no-store, must-revalidate'); +header('Pragma: no-cache'); +header('Expires: 0'); + +try { + $db = App\Database::getConnection(); + + // Support de ?group= et rétro-compatibilité avec ?cgroup= (FreePBX) + $group = 'all'; + if (!empty($_GET['group'])) { + $group = trim((string)$_GET['group']); + } elseif (!empty($_GET['cgroup'])) { + $group = trim((string)$_GET['cgroup']); + } + + // Génération du flux XML Yealink IP Phone + echo App\PhonebookManager::renderYealinkXml($db, $group); +} catch (\Throwable $e) { + // Si une erreur survient, on renvoie un XML d'annuaire valide vide avec le message d'erreur + $errMsg = htmlspecialchars($e->getMessage(), ENT_XML1, 'UTF-8'); + echo << + + Erreur Annuaire + + +XML; +} diff --git a/scripts/nexus-firewall b/scripts/nexus-firewall new file mode 100755 index 0000000..55326d1 --- /dev/null +++ b/scripts/nexus-firewall @@ -0,0 +1,79 @@ +#!/usr/bin/env php +exec("UPDATE settings SET firewall_enabled = 1, certbot_strict_port80 = 1 WHERE id = 1"); + shell_exec('systemctl start fail2ban 2>&1'); + $settings = FirewallManager::getSettings(); + FirewallManager::applyNginxConfig((int)$settings['admin_https_port'], 1); + @unlink('/tmp/nexus_fw_status_cache.json'); + echo "\033[32m[OK] Pare-feu NexusPBX activé avec succès (Mode Strict + Fail2ban démarré).\033[0m\n"; + break; + + case 'off': + case 'disable': + $db = Database::getConnection(); + $db->exec("UPDATE settings SET firewall_enabled = 0, certbot_strict_port80 = 0 WHERE id = 1"); + shell_exec('systemctl stop fail2ban 2>&1'); + $settings = FirewallManager::getSettings(); + FirewallManager::applyNginxConfig((int)$settings['admin_https_port'], 0); + @unlink('/tmp/nexus_fw_status_cache.json'); + echo "\033[33m[OK] Pare-feu NexusPBX désactivé (Mode normal + Fail2ban arrêté).\033[0m\n"; + break; + + case 'unban': + $ip = $argv[2] ?? ''; + if (!$ip) { + echo "Usage: nexus-firewall unban \n"; + exit(1); + } + $res = FirewallManager::unbanIp($ip, 'all'); + echo ($res['success'] ? "\033[32m[OK]\033[0m " : "\033[31m[ERREUR]\033[0m ") . $res['message'] . "\n"; + break; + + case 'ban': + $ip = $argv[2] ?? ''; + $jail = $argv[3] ?? 'asterisk'; + if (!$ip) { + echo "Usage: nexus-firewall ban [jail]\n"; + exit(1); + } + $res = FirewallManager::banIp($ip, $jail); + echo ($res['success'] ? "\033[32m[OK]\033[0m " : "\033[31m[ERREUR]\033[0m ") . $res['message'] . "\n"; + break; + + default: + echo "Usage: nexus-firewall [status|on|off|ban |unban ]\n"; + exit(1); +} + diff --git a/scripts/nexus_autobackup.php b/scripts/nexus_autobackup.php new file mode 100755 index 0000000..6c4c674 --- /dev/null +++ b/scripts/nexus_autobackup.php @@ -0,0 +1,23 @@ +getMessage() . "\n"; + exit(1); +} + diff --git a/scripts/setup-debian.sh b/scripts/setup-debian.sh new file mode 100755 index 0000000..78653fd --- /dev/null +++ b/scripts/setup-debian.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash +set -euo pipefail + +echo "=== Configuration Debian pour NexusPBX ===" + +# 1. Ajout de www-data au groupe asterisk +usermod -aG asterisk www-data + +# 2. Création d'un certificat TLS local de secours pour PJSIP TLS (port 5061) +mkdir -p /etc/asterisk/keys +if [ ! -f /etc/asterisk/keys/asterisk.crt ]; then + openssl req -x509 -nodes -days 3650 -newkey rsa:2048 \ + -keyout /etc/asterisk/keys/asterisk.key \ + -out /etc/asterisk/keys/asterisk.crt \ + -subj "/C=FR/ST=IDF/L=Paris/O=NexusPBX/CN=pbx.local" 2>/dev/null +fi + +# 3. Configuration de l'AMI (/etc/asterisk/manager.conf) sur 127.0.0.1:5038 +cat << 'EOF' > /etc/asterisk/manager.conf +[general] +enabled = yes +port = 5038 +bindaddr = 127.0.0.1 +displayconnects = no +timestampevents = yes + +[pbxgui] +secret = AsteriskAmi2026! +deny = 0.0.0.0/0.0.0.0 +permit = 127.0.0.1/255.255.255.0 +read = all +write = all +EOF + +# 4. Fichiers .custom.conf initiaux et inclusions dans pjsip.conf et extensions.conf +touch /etc/asterisk/pjsip.custom.conf /etc/asterisk/extensions.custom.conf + +if ! grep -q 'pjsip.custom.conf' /etc/asterisk/pjsip.conf; then + printf '\n#tryinclude "pjsip.custom.conf"\n' >> /etc/asterisk/pjsip.conf +fi + +if ! grep -q 'extensions.custom.conf' /etc/asterisk/extensions.conf; then + printf '\n#tryinclude "extensions.custom.conf"\n' >> /etc/asterisk/extensions.conf +fi + +# 5. Permissions Debian strictes pour asterisk:asterisk et accès groupe www-data +chown -R asterisk:asterisk /etc/asterisk +chmod 2775 /etc/asterisk +chmod 2750 /etc/asterisk/keys +chmod 0640 /etc/asterisk/keys/asterisk.key /etc/asterisk/keys/asterisk.crt +chmod 0660 /etc/asterisk/pjsip.custom.conf /etc/asterisk/extensions.custom.conf +chmod 0664 /etc/asterisk/pjsip.conf /etc/asterisk/extensions.conf +chmod 0640 /etc/asterisk/manager.conf + +# 6. Permissions du projet Web et des répertoires de stockage / sauvegardes +mkdir -p /var/www/nexus-pbx/storage/backups +mkdir -p /var/www/nexus-pbx/storage/system_backups +mkdir -p /var/www/nexus-pbx/storage/sounds +mkdir -p /var/lib/asterisk/sounds/custom +mkdir -p /var/spool/asterisk/voicemail/default + +chown -R www-data:asterisk /var/www/nexus-pbx +chmod 2775 /var/www/nexus-pbx/storage /var/www/nexus-pbx/storage/backups /var/www/nexus-pbx/storage/system_backups /var/www/nexus-pbx/storage/sounds +chown -R asterisk:asterisk /var/lib/asterisk/sounds /var/spool/asterisk/voicemail +chmod -R g+rwX /var/lib/asterisk/sounds /var/spool/asterisk/voicemail + +# Configuration du cron pour sauvegardes automatiques NexusPBX +cat << 'EOF' > /etc/cron.d/nexus-backup +# Sauvegardes programmées automatiques NexusPBX +*/10 * * * * www-data /usr/bin/php /var/www/nexus-pbx/scripts/nexus_autobackup.php >> /var/log/nexus_backup.log 2>&1 +EOF +chmod 0644 /etc/cron.d/nexus-backup +touch /var/log/nexus_backup.log && chown www-data:asterisk /var/log/nexus_backup.log && chmod 664 /var/log/nexus_backup.log + +# 7. Configuration du VirtualHost Nginx (Port 80) +PHP_SOCK=$(find /run/php/ -name "php*-fpm.sock" | head -n 1) +if [ -z "$PHP_SOCK" ]; then + PHP_SOCK="/run/php/php8.4-fpm.sock" +fi + +cat << EOF > /etc/nginx/sites-available/nexus-pbx +server { + listen 80 default_server; + listen [::]:80 default_server; + server_name _; + + root /var/www/nexus-pbx/public; + index index.php; + + access_log /var/log/nginx/nexus-pbx.access.log; + error_log /var/log/nginx/nexus-pbx.error.log; + + location / { + try_files \$uri \$uri/ /index.php?\$query_string; + } + + location ~ \.php\$ { + include snippets/fastcgi-php.conf; + fastcgi_pass unix:${PHP_SOCK}; + fastcgi_param SCRIPT_FILENAME \$document_root\$fastcgi_script_name; + } + + location ~ /\.(?!well-known).* { + deny all; + } +} +EOF + +chown root:asterisk /etc/nginx/sites-available/nexus-pbx +chmod 664 /etc/nginx/sites-available/nexus-pbx +rm -f /etc/nginx/sites-enabled/default +ln -sf /etc/nginx/sites-available/nexus-pbx /etc/nginx/sites-enabled/nexus-pbx + +# 8. Redémarrage des services Debian +systemctl restart php8.4-fpm +nginx -t && systemctl restart nginx +systemctl restart asterisk + +echo "=== Déploiement NexusPBX terminé avec succès ===" diff --git a/scripts/setup-nexus.sh b/scripts/setup-nexus.sh new file mode 120000 index 0000000..36547bd --- /dev/null +++ b/scripts/setup-nexus.sh @@ -0,0 +1 @@ +setup-debian.sh \ No newline at end of file diff --git a/scripts/voicemail_pass_sync.php b/scripts/voicemail_pass_sync.php new file mode 100755 index 0000000..b3968bf --- /dev/null +++ b/scripts/voicemail_pass_sync.php @@ -0,0 +1,29 @@ + +$context = $argv[1] ?? 'default'; +$mailbox = trim((string)($argv[2] ?? '')); +$newPass = trim((string)($argv[3] ?? '')); + +if ($mailbox === '' || $newPass === '') { + exit(0); +} + +require_once __DIR__ . '/../src/Database.php'; + +try { + $db = \App\Database::getConnection(); + $stmt = $db->prepare(' + UPDATE extensions + SET voicemail_pin = :pin, force_pin_change = 0 + WHERE extension = :ext + '); + $stmt->execute([ + ':pin' => $newPass, + ':ext' => $mailbox, + ]); +} catch (\Throwable $e) { + error_log("[voicemail_pass_sync] Erreur synchronisation PIN : " . $e->getMessage()); +} + diff --git a/src/AmiClient.php b/src/AmiClient.php new file mode 100644 index 0000000..7165035 --- /dev/null +++ b/src/AmiClient.php @@ -0,0 +1,227 @@ +query('SELECT ami_host, ami_port, ami_user, ami_secret FROM settings WHERE id = 1')->fetch(); + return new self( + (string)($row['ami_host'] ?? '127.0.0.1'), + (int)($row['ami_port'] ?? 5038), + (string)($row['ami_user'] ?? 'pbxgui'), + (string)($row['ami_secret'] ?? 'AsteriskAmi2026!') + ); + } + + /** + * Teste la connexion et l'authentification AMI + */ + public function testConnection(): array + { + $fp = $this->connectAndLogin($banner); + $versionOutput = $this->sendAction($fp, [ + 'Action' => 'Command', + 'Command' => 'core show version', + ]); + $this->logoff($fp); + + return [ + 'connected' => true, + 'banner' => trim($banner), + 'version' => $this->extractCommandOutput($versionOutput), + ]; + } + + /** + * Recharge à chaud l'ensemble des modules PJSIP, force le Re-Register SIP immédiat des Trunks, et recharge le Dialplan via AMI + */ + public function reloadPjsipAndDialplan(): array + { + $fp = $this->connectAndLogin($banner); + + // Recharge l'intégralité de la pile PJSIP (res_pjsip.so, res_pjsip_outbound_registration.so, res_pjsip_endpoint_identifier_ip.so, etc.) + $pjsipRes = $this->sendAction($fp, [ + 'Action' => 'Command', + 'Command' => 'pjsip reload', + ]); + + // Force l'envoi immédiat d'une requête SIP REGISTER vers tous les opérateurs (reprend immédiatement la main si un autre PBX était enregistré) + $regRes = $this->sendAction($fp, [ + 'Action' => 'Command', + 'Command' => 'pjsip send register *all', + ]); + + // Recharge le dialplan (extensions.conf / extensions.custom.conf) + $dialplanRes = $this->sendAction($fp, [ + 'Action' => 'Command', + 'Command' => 'dialplan reload', + ]); + + // Recharge les classes de musique d'attente (musiconhold.conf) + $mohRes = $this->sendAction($fp, [ + 'Action' => 'Command', + 'Command' => 'moh reload', + ]); + + // Recharge le module RTP (plages de ports rtpstart/rtpend & NAT) + $rtpRes = $this->sendAction($fp, [ + 'Action' => 'Command', + 'Command' => 'module reload res_rtp_asterisk.so', + ]); + + // Recharge les touches en cours d'appel (features.conf : transferts ##, *2, etc.) + $featuresRes = $this->sendAction($fp, [ + 'Action' => 'Command', + 'Command' => 'module reload features', + ]); + + // Recharge la messagerie vocale (voicemail.conf & voicemail.custom.conf) + $voicemailRes = $this->sendAction($fp, [ + 'Action' => 'Command', + 'Command' => 'voicemail reload', + ]); + + $this->logoff($fp); + + // Laisse 1.25s au scheduler PJSIP (1s) + registrar opérateur (OVH) pour répondre 200 OK avant le rafraîchissement de l'état UI + usleep(1250000); + + return [ + 'banner' => trim($banner), + 'pjsip' => $this->extractCommandOutput($pjsipRes), + 'register' => $this->extractCommandOutput($regRes), + 'dialplan' => $this->extractCommandOutput($dialplanRes), + 'moh' => $this->extractCommandOutput($mohRes), + ]; + } + + /** + * Force l'envoi immédiat d'un paquet SIP REGISTER sur un trunk précis ou tous les trunks (*all) + */ + public function forceRegister(string $regObject = '*all'): string + { + $fp = $this->connectAndLogin($banner); + $raw = $this->sendAction($fp, [ + 'Action' => 'Command', + 'Command' => 'pjsip send register ' . $regObject, + ]); + $this->logoff($fp); + usleep(1250000); + return $this->extractCommandOutput($raw); + } + + /** + * Exécute une commande CLI Asterisk via AMI (ex: "pjsip show endpoints") + */ + public function runCommand(string $cliCommand): string + { + $fp = $this->connectAndLogin($banner); + $raw = $this->sendAction($fp, [ + 'Action' => 'Command', + 'Command' => $cliCommand, + ]); + $this->logoff($fp); + return $this->extractCommandOutput($raw); + } + + /** + * @return resource + */ + private function connectAndLogin(?string &$banner = null) + { + $errno = 0; + $errstr = ''; + $fp = @fsockopen($this->host, $this->port, $errno, $errstr, $this->timeout); + if (!$fp) { + throw new RuntimeException("Connexion socket AMI refusée sur {$this->host}:{$this->port} ({$errstr} [{$errno}]). Vérifiez qu'Asterisk et manager.conf sont actifs."); + } + + stream_set_timeout($fp, $this->timeout); + $banner = (string)fgets($fp, 1024); + + $loginResp = $this->sendAction($fp, [ + 'Action' => 'Login', + 'Username' => $this->username, + 'Secret' => $this->secret, + 'Events' => 'off', + ]); + + if (!preg_match('/Response:\s*Success/i', $loginResp)) { + fclose($fp); + throw new RuntimeException("Échec d'authentification AMI pour l'utilisateur '{$this->username}' sur {$this->host}:{$this->port}."); + } + + return $fp; + } + + /** + * @param resource $fp + */ + private function sendAction($fp, array $headers): string + { + $packet = ''; + foreach ($headers as $k => $v) { + $packet .= "{$k}: {$v}\r\n"; + } + $packet .= "\r\n"; + + fwrite($fp, $packet); + + $response = ''; + while (!feof($fp)) { + $line = fgets($fp, 4096); + if ($line === false) { + break; + } + $response .= $line; + if (str_ends_with($response, "\r\n\r\n") || str_ends_with($response, "\n\n")) { + break; + } + } + + return $response; + } + + /** + * @param resource $fp + */ + private function logoff($fp): void + { + @fwrite($fp, "Action: Logoff\r\n\r\n"); + @fclose($fp); + } + + private function extractCommandOutput(string $raw): string + { + $lines = preg_split('/\r?\n/', trim($raw)) ?: []; + $clean = []; + foreach ($lines as $line) { + if (preg_match('/^(Response|Privilege|ActionID|Message):/i', $line)) { + continue; + } + if (str_starts_with($line, 'Output:')) { + $val = ltrim(substr($line, 7)); + if ($val !== '') { + $clean[] = $val; + } + } elseif ($line !== '--END COMMAND--') { + $clean[] = $line; + } + } + return trim(implode("\n", $clean)); + } +} diff --git a/src/Auth.php b/src/Auth.php new file mode 100644 index 0000000..522b61e --- /dev/null +++ b/src/Auth.php @@ -0,0 +1,81 @@ + true, + 'cookie_samesite' => 'Lax', + ]); + } + } + + public static function isAuthenticated(): bool + { + self::startSession(); + return !empty($_SESSION['user_id']) && !empty($_SESSION['username']); + } + + public static function getUsername(): string + { + self::startSession(); + return (string)($_SESSION['username'] ?? ''); + } + + public static function login(PDO $db, string $username, string $password): bool + { + self::startSession(); + $stmt = $db->prepare('SELECT id, username, password_hash FROM users WHERE username = :u LIMIT 1'); + $stmt->execute([':u' => trim($username)]); + $user = $stmt->fetch(); + + if ($user && password_verify($password, $user['password_hash'])) { + session_regenerate_id(true); + $_SESSION['user_id'] = (int)$user['id']; + $_SESSION['username'] = $user['username']; + return true; + } + + return false; + } + + public static function logout(): void + { + self::startSession(); + $_SESSION = []; + if (ini_get('session.use_cookies')) { + $params = session_get_cookie_params(); + setcookie( + session_name(), + '', + time() - 42000, + $params['path'], + $params['domain'], + $params['secure'], + $params['httponly'] + ); + } + session_destroy(); + } + + public static function updatePassword(PDO $db, string $username, string $newPassword): void + { + $stmt = $db->prepare('UPDATE users SET password_hash = :p WHERE username = :u'); + $stmt->execute([ + ':p' => password_hash($newPassword, PASSWORD_DEFAULT), + ':u' => $username, + ]); + } +} + diff --git a/src/BackupManager.php b/src/BackupManager.php new file mode 100644 index 0000000..86efad4 --- /dev/null +++ b/src/BackupManager.php @@ -0,0 +1,879 @@ +query("SELECT + backup_auto_enabled, backup_frequency, backup_retention, backup_storage_type, + backup_ssh_host, backup_ssh_port, backup_ssh_user, backup_ssh_auth_type, + backup_ssh_password, backup_ssh_remote_path, backup_last_run, backup_last_status + FROM settings WHERE id = 1"); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + + if (!$row) { + return [ + 'backup_auto_enabled' => 0, + 'backup_frequency' => 'daily', + 'backup_retention' => 7, + 'backup_storage_type' => 'local', + 'backup_ssh_host' => '', + 'backup_ssh_port' => 22, + 'backup_ssh_user' => '', + 'backup_ssh_auth_type' => 'password', + 'backup_ssh_password' => '', + 'backup_ssh_remote_path' => '/backups/nexus', + 'backup_last_run' => null, + 'backup_last_status' => '', + ]; + } + + return [ + 'backup_auto_enabled' => (int)($row['backup_auto_enabled'] ?? 0), + 'backup_frequency' => (string)($row['backup_frequency'] ?? 'daily'), + 'backup_retention' => (int)($row['backup_retention'] ?? 7), + 'backup_storage_type' => (string)($row['backup_storage_type'] ?? 'local'), + 'backup_ssh_host' => (string)($row['backup_ssh_host'] ?? ''), + 'backup_ssh_port' => (int)($row['backup_ssh_port'] ?? 22), + 'backup_ssh_user' => (string)($row['backup_ssh_user'] ?? ''), + 'backup_ssh_auth_type' => (string)($row['backup_ssh_auth_type'] ?? 'password'), + 'backup_ssh_password' => (string)($row['backup_ssh_password'] ?? ''), + 'backup_ssh_remote_path' => (string)($row['backup_ssh_remote_path'] ?? '/backups/nexus'), + 'backup_last_run' => $row['backup_last_run'] ?? null, + 'backup_last_status' => (string)($row['backup_last_status'] ?? ''), + ]; + } + + /** + * Sauvegarde les paramètres de configuration des backups. + */ + public static function saveBackupSettings(array $data): void + { + $db = Database::getConnection(); + + $autoEnabled = !empty($data['backup_auto_enabled']) ? 1 : 0; + $frequency = in_array($data['backup_frequency'] ?? '', ['hourly', '6h', '12h', 'daily', 'weekly'], true) + ? $data['backup_frequency'] + : 'daily'; + $retention = max(1, min(60, (int)($data['backup_retention'] ?? 7))); + $storageType = ($data['backup_storage_type'] ?? 'local') === 'ssh' ? 'ssh' : 'local'; + $sshHost = trim((string)($data['backup_ssh_host'] ?? '')); + $sshPort = max(1, min(65535, (int)($data['backup_ssh_port'] ?? 22))); + $sshUser = trim((string)($data['backup_ssh_user'] ?? '')); + $sshAuthType = ($data['backup_ssh_auth_type'] ?? 'password') === 'key' ? 'key' : 'password'; + $sshPassword = (string)($data['backup_ssh_password'] ?? ''); + $sshRemotePath = trim((string)($data['backup_ssh_remote_path'] ?? '/backups/nexus')); + if ($sshRemotePath === '') { + $sshRemotePath = '/backups/nexus'; + } + + $stmt = $db->prepare("UPDATE settings SET + backup_auto_enabled = :auto, + backup_frequency = :freq, + backup_retention = :ret, + backup_storage_type = :stype, + backup_ssh_host = :shost, + backup_ssh_port = :sport, + backup_ssh_user = :suser, + backup_ssh_auth_type = :sauth, + backup_ssh_password = :spass, + backup_ssh_remote_path = :spath, + updated_at = CURRENT_TIMESTAMP + WHERE id = 1"); + + $stmt->execute([ + ':auto' => $autoEnabled, + ':freq' => $frequency, + ':ret' => $retention, + ':stype' => $storageType, + ':shost' => $sshHost, + ':sport' => $sshPort, + ':suser' => $sshUser, + ':sauth' => $sshAuthType, + ':spass' => $sshPassword, + ':spath' => $sshRemotePath, + ]); + } + + /** + * Teste la connexion SSH avec les identifiants fournis. + */ + public static function testSshConnection(array $cfg): array + { + $host = trim((string)($cfg['backup_ssh_host'] ?? '')); + $port = (int)($cfg['backup_ssh_port'] ?? 22); + $user = trim((string)($cfg['backup_ssh_user'] ?? '')); + $authType = ($cfg['backup_ssh_auth_type'] ?? 'password') === 'key' ? 'key' : 'password'; + $password = (string)($cfg['backup_ssh_password'] ?? ''); + $remotePath = trim((string)($cfg['backup_ssh_remote_path'] ?? '/backups/nexus')); + + if ($host === '' || $user === '') { + throw new InvalidArgumentException("L'hôte SSH et l'utilisateur distant sont obligatoires."); + } + + if ($authType === 'password' && $password === '') { + throw new InvalidArgumentException("Le mot de passe SSH est obligatoire."); + } + + $remoteDirEsc = escapeshellarg($remotePath); + $testCmd = "mkdir -p {$remoteDirEsc} && touch {$remoteDirEsc}/.nexus_test && rm -f {$remoteDirEsc}/.nexus_test && echo 'NEXUS_SSH_OK'"; + + $result = self::runSshCommand($host, $port, $user, $authType, $password, $testCmd); + + if ($result['exit_code'] !== 0 || !str_contains($result['output'], 'NEXUS_SSH_OK')) { + $err = trim($result['error'] ?: $result['output']); + return [ + 'success' => false, + 'message' => "Échec de connexion SSH ou dossier distant non accessible : " . ($err ?: 'Erreur inconnue (code ' . $result['exit_code'] . ')'), + ]; + } + + return [ + 'success' => true, + 'message' => "Connexion SSH réussie ! Dossier distant '{$remotePath}' accessible et prêt pour les sauvegardes.", + ]; + } + + /** + * Crée une archive de sauvegarde complète (Base de données SQLite, sons, messageries, certs, confs). + * + * @param string $source 'manual' ou 'cron' + * @return array + */ + public static function createBackup(string $source = 'manual'): array + { + self::initDirectory(); + $settings = self::getBackupSettings(); + $db = Database::getConnection(); + + // 1. Checkpoint WAL SQLite pour garantir l'intégrité absolue de la base + try { + $db->exec("PRAGMA wal_checkpoint(TRUNCATE)"); + } catch (Exception $e) { + // Ignorer si échec temporaire + } + + $timestamp = date('Ymd_His'); + $filename = "nexus_backup_v" . self::VERSION . "_{$timestamp}.tar.gz"; + $localArchivePath = self::BACKUP_DIR . '/' . $filename; + + // 2. Préparation du dossier de staging + $stageDir = '/tmp/nexus_stage_' . uniqid() . '_' . $timestamp; + if (!mkdir($stageDir, 0700, true)) { + throw new RuntimeException("Impossible de créer le répertoire temporaire de staging : {$stageDir}"); + } + + $manifestFiles = []; + + try { + // A. Copie de la base SQLite + $dbSource = Database::getDatabasePath(); + if (file_exists($dbSource)) { + $dbDest = $stageDir . '/database.sqlite'; + copy($dbSource, $dbDest); + $manifestFiles['database.sqlite'] = [ + 'size' => filesize($dbDest), + 'md5' => md5_file($dbDest), + ]; + } + + // B. Copie des fichiers sons d'accueil et musiques personnalisées + // /var/www/nexus-pbx/storage/sounds/ + $storageSounds = '/var/www/nexus-pbx/storage/sounds'; + if (is_dir($storageSounds)) { + $destSounds = $stageDir . '/storage_sounds'; + mkdir($destSounds, 0755, true); + exec("rsync -a " . escapeshellarg($storageSounds . '/') . " " . escapeshellarg($destSounds . '/')); + $manifestFiles['storage_sounds'] = ['count' => count(glob($destSounds . '/*'))]; + } + + // /var/lib/asterisk/sounds/custom/ + $astSoundsCustom = '/var/lib/asterisk/sounds/custom'; + if (is_dir($astSoundsCustom)) { + $destAstSounds = $stageDir . '/asterisk_sounds_custom'; + mkdir($destAstSounds, 0755, true); + exec("rsync -a " . escapeshellarg($astSoundsCustom . '/') . " " . escapeshellarg($destAstSounds . '/')); + $manifestFiles['asterisk_sounds_custom'] = ['count' => count(glob($destAstSounds . '/*'))]; + } + + // C. Spool Voicemail (Messages vocaux, salutations personnalisées, secrets/pins) + $voicemailSpool = '/var/spool/asterisk/voicemail/default'; + if (is_dir($voicemailSpool)) { + $destVm = $stageDir . '/voicemail_default'; + mkdir($destVm, 0755, true); + exec("rsync -a " . escapeshellarg($voicemailSpool . '/') . " " . escapeshellarg($destVm . '/')); + $manifestFiles['voicemail_default'] = ['count' => count(glob($destVm . '/*'))]; + } + + // D. Certificats SSL / TLS personnalisés + $storageCerts = '/var/www/nexus-pbx/storage/certs'; + if (is_dir($storageCerts)) { + $destCerts = $stageDir . '/storage_certs'; + mkdir($destCerts, 0700, true); + exec("rsync -a " . escapeshellarg($storageCerts . '/') . " " . escapeshellarg($destCerts . '/')); + $manifestFiles['storage_certs'] = ['count' => count(glob($destCerts . '/*'))]; + } + $astKeys = '/etc/asterisk/keys'; + if (is_dir($astKeys)) { + $destAstKeys = $stageDir . '/asterisk_keys'; + mkdir($destAstKeys, 0700, true); + exec("rsync -a " . escapeshellarg($astKeys . '/') . " " . escapeshellarg($destAstKeys . '/')); + $manifestFiles['asterisk_keys'] = ['count' => count(glob($destAstKeys . '/*'))]; + } + + // E. Fichiers Asterisk Custom (.conf) + $destConfs = $stageDir . '/asterisk_custom_conf'; + mkdir($destConfs, 0755, true); + foreach (glob('/etc/asterisk/*.custom.conf') as $customConf) { + copy($customConf, $destConfs . '/' . basename($customConf)); + } + $manifestFiles['asterisk_custom_conf'] = ['count' => count(glob($destConfs . '/*'))]; + + // F. Création du Manifeste + $manifest = [ + 'nexus_version' => self::VERSION, + 'created_at' => date('c'), + 'timestamp' => time(), + 'filename' => $filename, + 'source' => $source, + 'storage_type' => $settings['backup_storage_type'], + 'hostname' => php_uname('n'), + 'os' => php_uname('s') . ' ' . php_uname('r'), + 'php_version' => PHP_VERSION, + 'components' => $manifestFiles, + ]; + file_put_contents($stageDir . '/manifest.json', json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + + // G. Compression en .tar.gz + $tarCmd = "tar -czf " . escapeshellarg($localArchivePath) . " -C " . escapeshellarg($stageDir) . " ."; + exec($tarCmd, $tarOut, $tarCode); + if ($tarCode !== 0 || !file_exists($localArchivePath)) { + throw new RuntimeException("Échec de la création de l'archive tar.gz (code {$tarCode})."); + } + + @chown($localArchivePath, 'www-data'); + @chgrp($localArchivePath, 'asterisk'); + @chmod($localArchivePath, 0664); + + $fileSize = filesize($localArchivePath); + + // 3. Gestion du stockage distant SSH si configuré + $storageType = $settings['backup_storage_type']; + $remoteSuccess = false; + + if ($storageType === 'ssh') { + $remotePath = rtrim($settings['backup_ssh_remote_path'], '/'); + $transferResult = self::transferToSsh($localArchivePath, $filename, $settings); + if (!$transferResult['success']) { + throw new RuntimeException("Échec du transfert distant SSH : " . $transferResult['message']); + } + $remoteSuccess = true; + + // L'utilisateur demande que la sauvegarde distante désactive la sauvegarde locale + // On supprime donc l'archive locale pour ne pas encombrer le disque local + @unlink($localArchivePath); + + // Pruning des sauvegardes distantes + self::pruneRemoteBackups($settings); + } else { + // Pruning des sauvegardes locales selon la rétention + self::pruneLocalBackups($settings['backup_retention']); + } + + // 4. Mettre à jour le statut dans la DB + $db->prepare("UPDATE settings SET + backup_last_run = :now, + backup_last_status = :status + WHERE id = 1")->execute([ + ':now' => date('Y-m-d H:i:s'), + ':status' => 'Succès (' . ($storageType === 'ssh' ? 'Distant SSH' : 'Local') . ' - ' . self::formatBytes($fileSize) . ')', + ]); + + return [ + 'success' => true, + 'filename' => $filename, + 'storage_type' => $storageType, + 'size' => $fileSize, + 'size_formatted' => self::formatBytes($fileSize), + 'created_at' => date('d/m/Y H:i:s'), + 'manifest' => $manifest, + ]; + } finally { + // Nettoyage impératif du dossier staging + self::removeDirRecursive($stageDir); + } + } + + /** + * Liste toutes les sauvegardes disponibles (locales ou distantes). + */ + public static function listBackups(): array + { + self::initDirectory(); + $settings = self::getBackupSettings(); + $backups = []; + + // 1. Sauvegardes locales + $localFiles = glob(self::BACKUP_DIR . '/*.tar.gz') ?: []; + foreach ($localFiles as $file) { + $name = basename($file); + $size = filesize($file); + $mtime = filemtime($file); + + // Tenter de lire le manifest depuis l'archive sans l'extraire entièrement + $manifest = self::readManifestFromTar($file); + + $backups[] = [ + 'filename' => $name, + 'storage_type' => 'local', + 'size' => $size, + 'size_formatted' => self::formatBytes($size), + 'timestamp' => $mtime, + 'date_formatted' => date('d/m/Y H:i:s', $mtime), + 'version' => $manifest['nexus_version'] ?? self::VERSION, + 'source' => $manifest['source'] ?? 'local', + 'is_local' => true, + ]; + } + + // 2. Sauvegardes distantes SSH (si stockage distant SSH configuré) + if ($settings['backup_storage_type'] === 'ssh' && !empty($settings['backup_ssh_host'])) { + $remoteBackups = self::listRemoteBackups($settings); + foreach ($remoteBackups as $rb) { + $backups[] = $rb; + } + } + + // Trier par date décroissante (plus récent au plus ancien) + usort($backups, fn($a, $b) => $b['timestamp'] <=> $a['timestamp']); + + return $backups; + } + + /** + * Supprime une sauvegarde (locale ou distante). + */ + public static function deleteBackup(string $filename): bool + { + $filename = basename($filename); + if ($filename === '' || !str_ends_with($filename, '.tar.gz')) { + throw new InvalidArgumentException("Nom de fichier de sauvegarde invalide."); + } + + $localPath = self::BACKUP_DIR . '/' . $filename; + if (file_exists($localPath)) { + return @unlink($localPath); + } + + // Si non présent en local, vérifier distant SSH + $settings = self::getBackupSettings(); + if ($settings['backup_storage_type'] === 'ssh' && !empty($settings['backup_ssh_host'])) { + $remoteFile = rtrim($settings['backup_ssh_remote_path'], '/') . '/' . $filename; + $res = self::runSshCommand( + $settings['backup_ssh_host'], + $settings['backup_ssh_port'], + $settings['backup_ssh_user'], + $settings['backup_ssh_auth_type'], + $settings['backup_ssh_password'], + "rm -f " . escapeshellarg($remoteFile) + ); + return $res['exit_code'] === 0; + } + + return false; + } + + /** + * Télécharge une sauvegarde (si distante, la rapatrie temporairement pour la streamer). + */ + public static function downloadBackup(string $filename): void + { + $filename = basename($filename); + if ($filename === '' || !str_ends_with($filename, '.tar.gz')) { + http_response_code(400); + die("Nom de sauvegarde invalide."); + } + + $localPath = self::BACKUP_DIR . '/' . $filename; + $tempDownload = false; + + if (!file_exists($localPath)) { + // Rapatriement depuis SSH + $settings = self::getBackupSettings(); + if ($settings['backup_storage_type'] !== 'ssh' || empty($settings['backup_ssh_host'])) { + http_response_code(404); + die("Sauvegarde introuvable."); + } + + $localPath = '/tmp/' . uniqid('dl_') . '_' . $filename; + $remoteFile = rtrim($settings['backup_ssh_remote_path'], '/') . '/' . $filename; + $fetchRes = self::fetchFromSsh($remoteFile, $localPath, $settings); + if (!$fetchRes['success'] || !file_exists($localPath)) { + http_response_code(502); + die("Impossible de récupérer la sauvegarde distante : " . $fetchRes['message']); + } + $tempDownload = true; + } + + if (ob_get_level()) { + ob_end_clean(); + } + + header('Content-Description: File Transfer'); + header('Content-Type: application/gzip'); + header('Content-Disposition: attachment; filename="' . $filename . '"'); + header('Expires: 0'); + header('Cache-Control: must-revalidate'); + header('Pragma: public'); + header('Content-Length: ' . filesize($localPath)); + readfile($localPath); + + if ($tempDownload) { + @unlink($localPath); + } + exit; + } + + /** + * Restaure une sauvegarde complète du système PBX en un seul clic ! + * + * @param string $archivePath Chemin absolu du fichier .tar.gz (déjà présent ou uploadé) + * @return array + */ + public static function restoreBackup(string $archivePath): array + { + if (!file_exists($archivePath) || !is_readable($archivePath)) { + throw new RuntimeException("Fichier d'archive introuvable ou inaccessible."); + } + + // 1. Vérifier la validité de l'archive tar.gz + exec("tar -tzf " . escapeshellarg($archivePath) . " 2>&1", $tarList, $testCode); + if ($testCode !== 0) { + throw new RuntimeException("L'archive fournie est corrompue ou n'est pas un fichier tar.gz valide."); + } + + $stageDir = '/tmp/nexus_restore_' . uniqid(); + if (!mkdir($stageDir, 0700, true)) { + throw new RuntimeException("Impossible de créer le dossier de décompression."); + } + + try { + // 2. Extraction complète dans le dossier de staging + exec("tar -xzf " . escapeshellarg($archivePath) . " -C " . escapeshellarg($stageDir), $extOut, $extCode); + if ($extCode !== 0) { + throw new RuntimeException("Échec de décompression de l'archive de sauvegarde."); + } + + // 3. Vérification du manifest et de la base de données SQLite + $manifestPath = $stageDir . '/manifest.json'; + $dbPath = $stageDir . '/database.sqlite'; + + if (!file_exists($dbPath)) { + throw new RuntimeException("L'archive ne contient pas de base de données NexusPBX (database.sqlite). Restauration annulée."); + } + + $manifest = []; + if (file_exists($manifestPath)) { + $manifest = json_decode((string)file_get_contents($manifestPath), true) ?: []; + } + + // 4. Sécurité : Création d'un backup de précaution de la base actuelle + $currentDb = Database::getDatabasePath(); + if (file_exists($currentDb)) { + @copy($currentDb, $currentDb . '.prerestore.' . date('Ymd_His')); + } + + // 5. Restauration de la Base de données SQLite + // Remplacer la base SQLite + copy($dbPath, $currentDb); + @chown($currentDb, 'www-data'); + @chgrp($currentDb, 'asterisk'); + @chmod($currentDb, 0660); + + // Supprimer d'éventuels fichiers WAL/SHM résiduels de l'ancienne base + if (file_exists($currentDb . '-wal')) { + @unlink($currentDb . '-wal'); + } + if (file_exists($currentDb . '-shm')) { + @unlink($currentDb . '-shm'); + } + + // 6. Restauration des fichiers sons (NexusPBX & Asterisk) + if (is_dir($stageDir . '/storage_sounds')) { + $destSounds = '/var/www/nexus-pbx/storage/sounds'; + @mkdir($destSounds, 0775, true); + exec("rsync -a --delete " . escapeshellarg($stageDir . '/storage_sounds/') . " " . escapeshellarg($destSounds . '/')); + @chown($destSounds, 'www-data'); + @chgrp($destSounds, 'asterisk'); + exec("chown -R www-data:asterisk " . escapeshellarg($destSounds)); + exec("chmod -R 775 " . escapeshellarg($destSounds)); + } + + if (is_dir($stageDir . '/asterisk_sounds_custom')) { + $destAstSounds = '/var/lib/asterisk/sounds/custom'; + @mkdir($destAstSounds, 0775, true); + exec("rsync -a " . escapeshellarg($stageDir . '/asterisk_sounds_custom/') . " " . escapeshellarg($destAstSounds . '/')); + exec("chown -R asterisk:asterisk " . escapeshellarg($destAstSounds)); + exec("chmod -R 775 " . escapeshellarg($destAstSounds)); + } + + // 7. Restauration de la boîte vocale (Voicemail Spool & Greetings) + if (is_dir($stageDir . '/voicemail_default')) { + $destVm = '/var/spool/asterisk/voicemail/default'; + @mkdir($destVm, 0775, true); + exec("rsync -a " . escapeshellarg($stageDir . '/voicemail_default/') . " " . escapeshellarg($destVm . '/')); + exec("chown -R asterisk:asterisk /var/spool/asterisk/voicemail"); + exec("chmod -R g+rwX /var/spool/asterisk/voicemail"); + } + + // 8. Restauration des certificats TLS + if (is_dir($stageDir . '/storage_certs')) { + $destCerts = '/var/www/nexus-pbx/storage/certs'; + @mkdir($destCerts, 0750, true); + exec("rsync -a " . escapeshellarg($stageDir . '/storage_certs/') . " " . escapeshellarg($destCerts . '/')); + exec("chown -R www-data:asterisk " . escapeshellarg($destCerts)); + } + if (is_dir($stageDir . '/asterisk_keys')) { + $destKeys = '/etc/asterisk/keys'; + @mkdir($destKeys, 0750, true); + exec("rsync -a " . escapeshellarg($stageDir . '/asterisk_keys/') . " " . escapeshellarg($destKeys . '/')); + exec("chown -R asterisk:asterisk " . escapeshellarg($destKeys)); + } + + // 9. Restauration des fichiers custom Asterisk (.conf) + if (is_dir($stageDir . '/asterisk_custom_conf')) { + foreach (glob($stageDir . '/asterisk_custom_conf/*.custom.conf') as $cf) { + copy($cf, '/etc/asterisk/' . basename($cf)); + @chown('/etc/asterisk/' . basename($cf), 'asterisk'); + @chgrp('/etc/asterisk/' . basename($cf), 'asterisk'); + @chmod('/etc/asterisk/' . basename($cf), 0664); + } + } + + // 10. Régénération intégrale des configurations Asterisk avec la nouvelle base restaurée + Database::resetConnection(); + $dbFresh = Database::getConnection(true); + $generator = new ConfigGenerator($dbFresh); + $generator->writeAllConfigs(); + + // 11. Rechargement du moteur Asterisk via AMI + try { + $ami = AmiClient::fromDatabase($dbFresh); + $reloadRes = $ami->reloadPjsipAndDialplan(); + } catch (Exception $e) { + $reloadRes = ['success' => false, 'error' => $e->getMessage()]; + } + + return [ + 'success' => true, + 'message' => 'Restauration complète effectuée avec succès ! Base de données réintégrée, fichiers audio, messageries vocales et configurations Asterisk rechargées.', + 'reload_success' => $reloadRes['success'] ?? false, + 'manifest' => $manifest, + ]; + } finally { + self::removeDirRecursive($stageDir); + } + } + + /** + * Exécute le script cron de sauvegarde automatique périodique. + */ + public static function runAutoBackupCron(): array + { + $settings = self::getBackupSettings(); + if (empty($settings['backup_auto_enabled'])) { + return ['status' => 'skipped', 'message' => 'Sauvegardes automatiques désactivées.']; + } + + $freq = $settings['backup_frequency']; + $lastRun = $settings['backup_last_run'] ? strtotime($settings['backup_last_run']) : 0; + $now = time(); + + $intervals = [ + 'hourly' => 3600, + '6h' => 6 * 3600, + '12h' => 12 * 3600, + 'daily' => 24 * 3600, + 'weekly' => 7 * 24 * 3600, + ]; + + $intervalSeconds = $intervals[$freq] ?? 86400; + + // Marge de tolérance de 5 minutes + if (($now - $lastRun) < ($intervalSeconds - 300)) { + return [ + 'status' => 'skipped', + 'message' => "Sauvegarde automatique non requise actuellement (dernière exécutée il y a " . round(($now - $lastRun) / 60) . " min, fréquence: {$freq}).", + ]; + } + + return self::createBackup('cron'); + } + + // ========================================================================= + // MÉTHODES PRIVÉES / UTILITAIRES + // ========================================================================= + + private static function runSshCommand(string $host, int $port, string $user, string $authType, string $password, string $cmd): array + { + $sshOptions = "-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=8 -p " . (int)$port; + $target = escapeshellarg("{$user}@{$host}"); + + if ($authType === 'password') { + $fullCmd = "sshpass -p " . escapeshellarg($password) . " ssh {$sshOptions} {$target} " . escapeshellarg($cmd); + } else { + $fullCmd = "ssh {$sshOptions} {$target} " . escapeshellarg($cmd); + } + + $descriptors = [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + + $proc = proc_open($fullCmd, $descriptors, $pipes); + if (!is_resource($proc)) { + return ['exit_code' => 1, 'output' => '', 'error' => "Impossible de lancer le processus SSH."]; + } + + fclose($pipes[0]); + $output = stream_get_contents($pipes[1]); + fclose($pipes[1]); + $error = stream_get_contents($pipes[2]); + fclose($pipes[2]); + + $exitCode = proc_close($proc); + + return [ + 'exit_code' => $exitCode, + 'output' => (string)$output, + 'error' => (string)$error, + ]; + } + + private static function transferToSsh(string $localFile, string $filename, array $cfg): array + { + $host = $cfg['backup_ssh_host']; + $port = (int)$cfg['backup_ssh_port']; + $user = $cfg['backup_ssh_user']; + $authType = $cfg['backup_ssh_auth_type']; + $password = $cfg['backup_ssh_password']; + $remotePath = rtrim($cfg['backup_ssh_remote_path'], '/'); + + // S'assurer que le dossier distant existe + self::runSshCommand($host, $port, $user, $authType, $password, "mkdir -p " . escapeshellarg($remotePath)); + + $scpOptions = "-P " . (int)$port . " -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=10"; + $dest = escapeshellarg("{$user}@{$host}:{$remotePath}/{$filename}"); + $src = escapeshellarg($localFile); + + if ($authType === 'password') { + $scpCmd = "sshpass -p " . escapeshellarg($password) . " scp {$scpOptions} {$src} {$dest}"; + } else { + $scpCmd = "scp {$scpOptions} {$src} {$dest}"; + } + + exec($scpCmd . " 2>&1", $output, $code); + if ($code !== 0) { + return [ + 'success' => false, + 'message' => implode("\n", $output) ?: "Erreur SCP (code {$code})", + ]; + } + + return ['success' => true]; + } + + private static function fetchFromSsh(string $remoteFile, string $localDest, array $cfg): array + { + $host = $cfg['backup_ssh_host']; + $port = (int)$cfg['backup_ssh_port']; + $user = $cfg['backup_ssh_user']; + $authType = $cfg['backup_ssh_auth_type']; + $password = $cfg['backup_ssh_password']; + + $scpOptions = "-P " . (int)$port . " -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=10"; + $src = escapeshellarg("{$user}@{$host}:{$remoteFile}"); + $dest = escapeshellarg($localDest); + + if ($authType === 'password') { + $scpCmd = "sshpass -p " . escapeshellarg($password) . " scp {$scpOptions} {$src} {$dest}"; + } else { + $scpCmd = "scp {$scpOptions} {$src} {$dest}"; + } + + exec($scpCmd . " 2>&1", $output, $code); + if ($code !== 0) { + return [ + 'success' => false, + 'message' => implode("\n", $output) ?: "Erreur récupération SCP (code {$code})", + ]; + } + + return ['success' => true]; + } + + private static function listRemoteBackups(array $cfg): array + { + $host = $cfg['backup_ssh_host']; + $port = (int)$cfg['backup_ssh_port']; + $user = $cfg['backup_ssh_user']; + $authType = $cfg['backup_ssh_auth_type']; + $password = $cfg['backup_ssh_password']; + $remotePath = rtrim($cfg['backup_ssh_remote_path'], '/'); + + $listCmd = "ls -l --time-style=+%s " . escapeshellarg($remotePath) . "/*.tar.gz 2>/dev/null"; + $res = self::runSshCommand($host, $port, $user, $authType, $password, $listCmd); + + $results = []; + if ($res['exit_code'] === 0 && !empty($res['output'])) { + $lines = explode("\n", trim($res['output'])); + foreach ($lines as $line) { + // Format attendu: -rw-r--r-- 1 user group 12345 1698888888 /path/nexus_backup_v1.2.0_20261101_180000.tar.gz + $parts = preg_split('/\s+/', trim($line)); + if (count($parts) >= 7) { + $size = (int)$parts[4]; + $timestamp = (int)$parts[5]; + $fullPath = $parts[6]; + $name = basename($fullPath); + + if (str_ends_with($name, '.tar.gz')) { + $results[] = [ + 'filename' => $name, + 'storage_type' => 'ssh', + 'size' => $size, + 'size_formatted' => self::formatBytes($size), + 'timestamp' => $timestamp > 0 ? $timestamp : time(), + 'date_formatted' => date('d/m/Y H:i:s', $timestamp > 0 ? $timestamp : time()), + 'version' => self::VERSION, + 'source' => 'distant (SSH)', + 'is_local' => false, + ]; + } + } + } + } + + return $results; + } + + private static function pruneLocalBackups(int $retention): void + { + if ($retention <= 0) { + return; + } + + $files = glob(self::BACKUP_DIR . '/*.tar.gz') ?: []; + if (count($files) <= $retention) { + return; + } + + // Trier par date décroissante + usort($files, fn($a, $b) => filemtime($b) <=> filemtime($a)); + + // Supprimer ceux au-delà du quota de rétention + $toDelete = array_slice($files, $retention); + foreach ($toDelete as $f) { + @unlink($f); + } + } + + private static function pruneRemoteBackups(array $cfg): void + { + $retention = (int)($cfg['backup_retention'] ?? 7); + if ($retention <= 0) { + return; + } + + $remotePath = rtrim($cfg['backup_ssh_remote_path'], '/'); + // Script shell distant pour supprimer les fichiers les plus anciens au-delà de N + $pruneCmd = "cd " . escapeshellarg($remotePath) . " && ls -t *.tar.gz 2>/dev/null | tail -n +" . ($retention + 1) . " | xargs -r rm -f"; + self::runSshCommand( + $cfg['backup_ssh_host'], + $cfg['backup_ssh_port'], + $cfg['backup_ssh_user'], + $cfg['backup_ssh_auth_type'], + $cfg['backup_ssh_password'], + $pruneCmd + ); + } + + private static function readManifestFromTar(string $tarGzPath): ?array + { + // Lire manifest.json sans tout décompresser + $cmd = "tar -xzOf " . escapeshellarg($tarGzPath) . " ./manifest.json 2>/dev/null || tar -xzOf " . escapeshellarg($tarGzPath) . " manifest.json 2>/dev/null"; + $json = shell_exec($cmd); + if ($json) { + $parsed = json_decode($json, true); + if (is_array($parsed)) { + return $parsed; + } + } + return null; + } + + private static function formatBytes(int $bytes): string + { + if ($bytes >= 1073741824) { + return number_format($bytes / 1073741824, 2) . ' Go'; + } + if ($bytes >= 1048576) { + return number_format($bytes / 1048576, 2) . ' Mo'; + } + if ($bytes >= 1024) { + return number_format($bytes / 1024, 1) . ' Ko'; + } + return $bytes . ' B'; + } + + private static function removeDirRecursive(string $dir): void + { + if (!is_dir($dir)) { + return; + } + $items = scandir($dir); + if ($items === false) { + return; + } + foreach ($items as $item) { + if ($item === '.' || $item === '..') { + continue; + } + $path = $dir . '/' . $item; + if (is_dir($path)) { + self::removeDirRecursive($path); + } else { + @unlink($path); + } + } + @rmdir($dir); + } +} diff --git a/src/CertificateManager.php b/src/CertificateManager.php new file mode 100644 index 0000000..c59e7f8 --- /dev/null +++ b/src/CertificateManager.php @@ -0,0 +1,522 @@ +query('SELECT * FROM certificates ORDER BY is_default DESC, id DESC')->fetchAll(); + $now = time(); + + foreach ($certs as &$c) { + $validTo = !empty($c['valid_to']) ? strtotime($c['valid_to']) : null; + if ($validTo) { + $days = (int)round(($validTo - $now) / 86400); + $c['days_remaining'] = $days; + if ($days < 0) { + $c['status'] = 'expired'; + } elseif ($days <= 15) { + $c['status'] = 'expiring_soon'; + } else { + $c['status'] = 'valid'; + } + } + } + unset($c); + + return $certs; + } + + /** + * Récupère un certificat par son ID. + */ + public static function getById(PDO $db, int $id): ?array + { + $stmt = $db->prepare('SELECT * FROM certificates WHERE id = :id'); + $stmt->execute([':id' => $id]); + $cert = $stmt->fetch(); + return $cert ?: null; + } + + /** + * Récupère le certificat actif par défaut pour Asterisk PJSIP TLS et WebRTC. + */ + public static function getDefault(PDO $db): ?array + { + $stmt = $db->query('SELECT * FROM certificates WHERE is_default = 1 LIMIT 1'); + $cert = $stmt->fetch(); + if (!$cert) { + $stmt = $db->query('SELECT * FROM certificates ORDER BY id ASC LIMIT 1'); + $cert = $stmt->fetch(); + } + return $cert ?: null; + } + + /** + * Analyse et extrait les métadonnées d'un certificat au format PEM. + */ + public static function parseCertPem(string $certPem): array + { + $certPem = trim($certPem); + if ($certPem === '' || !str_contains($certPem, '-----BEGIN CERTIFICATE-----')) { + throw new InvalidArgumentException("Format de certificat invalide : aucun bloc '-----BEGIN CERTIFICATE-----' trouvé."); + } + + $parsed = @openssl_x509_parse($certPem, true); + if ($parsed === false) { + $parsed = @openssl_x509_parse($certPem, false); + } + if ($parsed === false) { + throw new InvalidArgumentException("Impossible d'analyser le certificat X.509 fourni (fichier corrompu ou format non supporté)."); + } + + $domain = $parsed['subject']['CN'] ?? ($parsed['subject']['commonName'] ?? ''); + $altNames = []; + if (!empty($parsed['extensions']['subjectAltName'])) { + $sanRaw = $parsed['extensions']['subjectAltName']; + $parts = explode(',', $sanRaw); + foreach ($parts as $p) { + $p = trim($p); + if (str_starts_with($p, 'DNS:')) { + $altNames[] = substr($p, 4); + } elseif (str_starts_with($p, 'IP Address:')) { + $altNames[] = substr($p, 11); + } + } + } + + if ($domain === '' && !empty($altNames)) { + $domain = $altNames[0]; + } + + $issuer = $parsed['issuer']['O'] ?? ($parsed['issuer']['organizationName'] ?? ($parsed['issuer']['CN'] ?? ($parsed['issuer']['commonName'] ?? 'Inconnu'))); + $validFrom = !empty($parsed['validFrom_time_t']) ? date('Y-m-d H:i:s', $parsed['validFrom_time_t']) : null; + $validTo = !empty($parsed['validTo_time_t']) ? date('Y-m-d H:i:s', $parsed['validTo_time_t']) : null; + $daysRemaining = !empty($parsed['validTo_time_t']) ? (int)round(($parsed['validTo_time_t'] - time()) / 86400) : 0; + + return [ + 'domain' => $domain ?: 'domaine-inconnu', + 'alt_names' => implode(', ', $altNames), + 'issuer' => $issuer, + 'valid_from' => $validFrom, + 'valid_to' => $validTo, + 'days_remaining' => $daysRemaining, + 'status' => ($daysRemaining > 15) ? 'valid' : (($daysRemaining >= 0) ? 'expiring_soon' : 'expired'), + ]; + } + + /** + * Valide qu'une clé privée correspond bien au certificat public. + */ + public static function validateKeyPair(string $certPem, string $keyPem, ?string $passphrase = null): bool + { + $certPem = trim($certPem); + $keyPem = trim($keyPem); + + if ($keyPem === '' || (!str_contains($keyPem, 'KEY-----') && !str_contains($keyPem, 'BEGIN RSA PRIVATE KEY'))) { + throw new InvalidArgumentException("Format de clé privée invalide : aucun bloc clé privée PEM détecté."); + } + + $privKeyResource = openssl_pkey_get_private($keyPem, $passphrase ?? ''); + if ($privKeyResource === false) { + throw new InvalidArgumentException("La clé privée est invalide ou chiffrée avec une phrase secrète non renseignée."); + } + + $match = openssl_x509_check_private_key($certPem, $privKeyResource); + if (!$match) { + throw new InvalidArgumentException("Incohérence de paire de clés : la clé privée fournie NE CORRESPOND PAS au certificat public."); + } + + return true; + } + + /** + * Enregistre un certificat importé manuellement (DigiCert, Sectigo, OVH, etc.). + */ + public static function saveCustomCert( + PDO $db, + string $name, + string $certPem, + string $keyPem, + string $chainPem = '', + bool $makeDefault = false + ): array { + $meta = self::parseCertPem($certPem); + self::validateKeyPair($certPem, $keyPem); + + $name = trim($name); + if ($name === '') { + $name = 'Certificat ' . $meta['domain']; + } + + $chainPem = trim($chainPem); + $fullchainPem = ($chainPem !== '') ? ($certPem . "\n" . $chainPem) : $certPem; + + // Insertion en base pour obtenir l'ID + $stmt = $db->prepare('INSERT INTO certificates ( + name, type, primary_domain, alt_names, issuer, valid_from, valid_to, days_remaining, + cert_path, key_path, chain_path, fullchain_path, email, auto_renew, is_default, status + ) VALUES ( + :name, :type, :domain, :alt_names, :issuer, :valid_from, :valid_to, :days_rem, + "", "", "", "", :email, 0, 0, :status + )'); + + $stmt->execute([ + ':name' => $name, + ':type' => 'custom', + ':domain' => $meta['domain'], + ':alt_names' => $meta['alt_names'], + ':issuer' => $meta['issuer'], + ':valid_from' => $meta['valid_from'], + ':valid_to' => $meta['valid_to'], + ':days_rem' => $meta['days_remaining'], + ':email' => '', + ':status' => $meta['status'], + ]); + + $certId = (int)$db->lastInsertId(); + $targetDir = self::KEYS_BASE_DIR . '/cert_' . $certId; + if (!is_dir($targetDir)) { + mkdir($targetDir, 0770, true); + } + + $certPath = $targetDir . '/cert.pem'; + $keyPath = $targetDir . '/privkey.pem'; + $chainPath = ($chainPem !== '') ? $targetDir . '/chain.pem' : ''; + $fullchainPath = $targetDir . '/fullchain.pem'; + + file_put_contents($certPath, $certPem . "\n"); + file_put_contents($keyPath, $keyPem . "\n"); + if ($chainPem !== '') { + file_put_contents($chainPath, $chainPem . "\n"); + } + file_put_contents($fullchainPath, $fullchainPem . "\n"); + + self::secureKeyPermissions($targetDir); + + $upd = $db->prepare('UPDATE certificates SET + cert_path = :cp, + key_path = :kp, + chain_path = :chp, + fullchain_path = :fcp + WHERE id = :id'); + $upd->execute([ + ':cp' => $certPath, + ':kp' => $keyPath, + ':chp' => $chainPath, + ':fcp' => $fullchainPath, + ':id' => $certId, + ]); + + if ($makeDefault) { + self::setDefault($db, $certId); + } + + return self::getById($db, $certId); + } + + /** + * Génère un certificat SSL automatique via Let's Encrypt (Certbot HTTP-01 Webroot). + */ + public static function requestLetsEncrypt( + PDO $db, + string $name, + string $domain, + string $email, + bool $makeDefault = true, + bool $staging = false + ): array { + $domain = strtolower(trim($domain)); + $email = trim($email); + + if (!preg_match('/^([a-z0-9]([a-z0-9\-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/i', $domain)) { + throw new InvalidArgumentException("Le nom de domaine '{$domain}' est invalide."); + } + + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { + throw new InvalidArgumentException("L'adresse email '{$email}' est invalide."); + } + + $name = trim($name); + if ($name === '') { + $name = "Let's Encrypt - " . $domain; + } + + $challengeDir = self::WEBROOT_DIR . '/.well-known/acme-challenge'; + if (!is_dir($challengeDir)) { + mkdir($challengeDir, 0775, true); + } + + $escDomain = escapeshellarg($domain); + $escEmail = escapeshellarg($email); + $escWebroot = escapeshellarg(self::WEBROOT_DIR); + + $stagingFlag = $staging ? '--test-cert' : ''; + + // Exécution de Certbot webroot non-interactive + $cmd = "certbot certonly --webroot -w {$escWebroot} -d {$escDomain} --non-interactive --agree-tos -m {$escEmail} --keep-until-expiring {$stagingFlag} 2>&1"; + + $output = []; + $exitCode = 0; + exec($cmd, $output, $exitCode); + $outputStr = implode("\n", $output); + + if ($exitCode !== 0) { + throw new RuntimeException("Échec de la génération Let's Encrypt (Code: {$exitCode}) :\n" . $outputStr); + } + + $liveDir = "/etc/letsencrypt/live/{$domain}"; + $liveCert = $liveDir . '/cert.pem'; + $liveKey = $liveDir . '/privkey.pem'; + $liveChain = $liveDir . '/chain.pem'; + $liveFullchain = $liveDir . '/fullchain.pem'; + + if (!file_exists($liveFullchain) || !file_exists($liveKey)) { + throw new RuntimeException("Certbot a terminé mais les fichiers de certificat n'ont pas été trouvés dans {$liveDir}."); + } + + $certPem = file_get_contents($liveCert); + $meta = self::parseCertPem($certPem); + + // Insertion en base + $stmt = $db->prepare('INSERT INTO certificates ( + name, type, primary_domain, alt_names, issuer, valid_from, valid_to, days_remaining, + cert_path, key_path, chain_path, fullchain_path, email, auto_renew, is_default, status + ) VALUES ( + :name, :type, :domain, :alt_names, :issuer, :valid_from, :valid_to, :days_rem, + "", "", "", "", :email, 1, 0, :status + )'); + + $stmt->execute([ + ':name' => $name, + ':type' => 'letsencrypt', + ':domain' => $domain, + ':alt_names' => $meta['alt_names'], + ':issuer' => "Let's Encrypt", + ':valid_from' => $meta['valid_from'], + ':valid_to' => $meta['valid_to'], + ':days_rem' => $meta['days_remaining'], + ':email' => $email, + ':status' => $meta['status'], + ]); + + $certId = (int)$db->lastInsertId(); + $targetDir = self::KEYS_BASE_DIR . '/cert_' . $certId; + if (!is_dir($targetDir)) { + mkdir($targetDir, 0770, true); + } + + $certPath = $targetDir . '/cert.pem'; + $keyPath = $targetDir . '/privkey.pem'; + $chainPath = $targetDir . '/chain.pem'; + $fullchainPath = $targetDir . '/fullchain.pem'; + + copy($liveCert, $certPath); + copy($liveKey, $keyPath); + copy($liveChain, $chainPath); + copy($liveFullchain, $fullchainPath); + + self::secureKeyPermissions($targetDir); + + $upd = $db->prepare('UPDATE certificates SET + cert_path = :cp, + key_path = :kp, + chain_path = :chp, + fullchain_path = :fcp + WHERE id = :id'); + $upd->execute([ + ':cp' => $certPath, + ':kp' => $keyPath, + ':chp' => $chainPath, + ':fcp' => $fullchainPath, + ':id' => $certId, + ]); + + if ($makeDefault) { + self::setDefault($db, $certId); + } + + return self::getById($db, $certId); + } + + /** + * Définit le certificat actif par défaut. + */ + public static function setDefault(PDO $db, int $id): bool + { + $cert = self::getById($db, $id); + if (!$cert) { + throw new InvalidArgumentException("Certificat introuvable (ID: {$id})."); + } + + $db->beginTransaction(); + try { + $db->exec('UPDATE certificates SET is_default = 0'); + $stmt = $db->prepare('UPDATE certificates SET is_default = 1 WHERE id = :id'); + $stmt->execute([':id' => $id]); + $db->commit(); + } catch (\Throwable $e) { + $db->rollBack(); + throw $e; + } + + // Synchronisation des fichiers actifs pour Nginx et Asterisk + $activeCertFile = self::KEYS_BASE_DIR . '/active_cert.crt'; + $activeKeyFile = self::KEYS_BASE_DIR . '/active_key.key'; + + $sourceCert = !empty($cert['fullchain_path']) && file_exists($cert['fullchain_path']) + ? $cert['fullchain_path'] + : $cert['cert_path']; + $sourceKey = $cert['key_path']; + + if (file_exists($sourceCert) && file_exists($sourceKey)) { + @copy($sourceCert, $activeCertFile); + @copy($sourceKey, $activeKeyFile); + @chmod($activeCertFile, 0640); + @chmod($activeKeyFile, 0640); + @chgrp($activeCertFile, 'asterisk'); + @chgrp($activeKeyFile, 'asterisk'); + } + + return true; + } + + /** + * Supprime un certificat. + */ + public static function delete(PDO $db, int $id): bool + { + $cert = self::getById($db, $id); + if (!$cert) { + throw new InvalidArgumentException("Certificat introuvable (ID: {$id})."); + } + + $totalCerts = (int)$db->query('SELECT COUNT(*) FROM certificates')->fetchColumn(); + if ($totalCerts <= 1) { + throw new RuntimeException("Impossible de supprimer le dernier certificat de la plateforme."); + } + + if ((int)$cert['is_default'] === 1) { + throw new RuntimeException("Ce certificat est actuellement actif par défaut. Veuillez d'abord activer un autre certificat."); + } + + $targetDir = self::KEYS_BASE_DIR . '/cert_' . $id; + if (is_dir($targetDir)) { + $files = glob($targetDir . '/*'); + foreach ($files as $f) { + if (is_file($f)) { + @unlink($f); + } + } + @rmdir($targetDir); + } + + $stmt = $db->prepare('DELETE FROM certificates WHERE id = :id'); + return $stmt->execute([':id' => $id]); + } + + /** + * Renouvelle un certificat Let's Encrypt. + */ + public static function renew(PDO $db, int $id): array + { + $cert = self::getById($db, $id); + if (!$cert) { + throw new InvalidArgumentException("Certificat introuvable (ID: {$id})."); + } + + if ($cert['type'] !== 'letsencrypt') { + throw new InvalidArgumentException("Seuls les certificats gérés par Let's Encrypt peuvent être renouvelés automatiquement. Pour les certificats personnalisés, importez la nouvelle version."); + } + + $domain = $cert['primary_domain']; + $escDomain = escapeshellarg($domain); + + $cmd = "certbot renew --cert-name {$escDomain} --force-renewal 2>&1"; + $output = []; + $exitCode = 0; + exec($cmd, $output, $exitCode); + $outputStr = implode("\n", $output); + + if ($exitCode !== 0) { + throw new RuntimeException("Échec du renouvellement Certbot (Code: {$exitCode}) :\n" . $outputStr); + } + + $liveDir = "/etc/letsencrypt/live/{$domain}"; + $liveCert = $liveDir . '/cert.pem'; + $liveKey = $liveDir . '/privkey.pem'; + $liveChain = $liveDir . '/chain.pem'; + $liveFullchain = $liveDir . '/fullchain.pem'; + + if (file_exists($liveCert)) { + $certPem = file_get_contents($liveCert); + $meta = self::parseCertPem($certPem); + + $targetDir = self::KEYS_BASE_DIR . '/cert_' . $id; + if (!is_dir($targetDir)) { + mkdir($targetDir, 0770, true); + } + + copy($liveCert, $targetDir . '/cert.pem'); + copy($liveKey, $targetDir . '/privkey.pem'); + copy($liveChain, $targetDir . '/chain.pem'); + copy($liveFullchain, $targetDir . '/fullchain.pem'); + + self::secureKeyPermissions($targetDir); + + $upd = $db->prepare('UPDATE certificates SET + valid_from = :vf, + valid_to = :vt, + days_remaining = :dr, + status = :st, + updated_at = CURRENT_TIMESTAMP + WHERE id = :id'); + $upd->execute([ + ':vf' => $meta['valid_from'], + ':vt' => $meta['valid_to'], + ':dr' => $meta['days_remaining'], + ':st' => $meta['status'], + ':id' => $id, + ]); + + if ((int)$cert['is_default'] === 1) { + self::setDefault($db, $id); + } + } + + return self::getById($db, $id); + } + + /** + * Applique les permissions sécurisées pour Asterisk. + */ + private static function secureKeyPermissions(string $dir): void + { + if (!is_dir($dir)) { + return; + } + + @chmod($dir, 0770); + @chgrp($dir, 'asterisk'); + + $files = glob($dir . '/*'); + foreach ($files as $f) { + if (is_file($f)) { + @chmod($f, 0640); + @chgrp($f, 'asterisk'); + } + } + } +} diff --git a/src/ConfigGenerator.php b/src/ConfigGenerator.php new file mode 100644 index 0000000..be4432c --- /dev/null +++ b/src/ConfigGenerator.php @@ -0,0 +1,1936 @@ +db->query('SELECT * FROM settings WHERE id = 1')->fetch() ?: []; + $extensions = $this->db->query('SELECT * FROM extensions WHERE enabled = 1 ORDER BY extension ASC')->fetchAll(); + $trunks = $this->db->query('SELECT * FROM trunks WHERE enabled = 1 ORDER BY id ASC')->fetchAll(); + $mohSounds = $this->db->query("SELECT id, slug, name FROM sounds WHERE usage_type = 'moh'")->fetchAll(); + $mohMap = []; + foreach ($mohSounds as $ms) { + $mohMap[(int)$ms['id']] = $ms; + } + + $date = date('Y-m-d H:i:s'); + $udpPort = (int)($settings['udp_port'] ?? 5060); + $tlsPort = (int)($settings['tls_port'] ?? 5061); + $extUdpPort = (int)($settings['external_udp_port'] ?? 0); + $extTlsPort = (int)($settings['external_tls_port'] ?? 0); + $leDomain = trim((string)($settings['letsencrypt_domain'] ?? '')); + $extHost = trim((string)($settings['external_ip'] ?? '')); + + // Multi-réseaux locaux (Local Networks) + $localNetworks = json_decode((string)($settings['local_networks_json'] ?? '[]'), true) ?: []; + if (empty($localNetworks)) { + $rawNet = trim((string)($settings['local_net'] ?? '')); + if ($rawNet !== '') { + $localNetworks = [$rawNet]; + } + } + + $lines = []; + $lines[] = "; ==========================================================================="; + $lines[] = "; FICHIER GÉNÉRÉ AUTOMATIQUEMENT PAR NEXUSPBX ($date)"; + $lines[] = "; NE PAS MODIFIER MANUELLEMENT - SOURCE UNIQUE DE VÉRITÉ : SQLITE"; + $lines[] = "; ==========================================================================="; + $lines[] = ""; + + // 1. Transport UDP (Postes internes LAN & Trunks standards) + $lines[] = "; --- TRANSPORT UDP (LAN / INTERNE) ---"; + $lines[] = "[transport-udp]"; + $lines[] = "type = transport"; + $lines[] = "protocol = udp"; + $lines[] = "bind = 0.0.0.0:{$udpPort}"; + $lines[] = "symmetric_transport = yes"; + foreach ($localNetworks as $net) { + $net = trim((string)$net); + if ($net !== '') { + $lines[] = "local_net = {$net}"; + } + } + if ($extHost !== '') { + $lines[] = "external_media_address = {$extHost}"; + $lines[] = "external_signaling_address = {$extHost}"; + if ($extUdpPort > 0) { + $lines[] = "external_signaling_port = {$extUdpPort}"; + } + } + $lines[] = "allow_reload = no"; + $lines[] = ""; + + // 1b. Transport TCP (Trunks opérateurs TCP) + $lines[] = "; --- TRANSPORT TCP (OPÉRATEURS / TRUNKS TCP) ---"; + $lines[] = "[transport-tcp]"; + $lines[] = "type = transport"; + $lines[] = "protocol = tcp"; + $lines[] = "bind = 0.0.0.0:{$udpPort}"; + $lines[] = "symmetric_transport = yes"; + foreach ($localNetworks as $net) { + $net = trim((string)$net); + if ($net !== '') { + $lines[] = "local_net = {$net}"; + } + } + if ($extHost !== '') { + $lines[] = "external_media_address = {$extHost}"; + $lines[] = "external_signaling_address = {$extHost}"; + if ($extUdpPort > 0) { + $lines[] = "external_signaling_port = {$extUdpPort}"; + } + } + $lines[] = "allow_reload = no"; + $lines[] = ""; + + // 2. Transport TLS (Certificat sélectionné dans le Gestionnaire SSL / TLS) + $activeCert = null; + try { + $stmt = $this->db->query("SELECT * FROM certificates WHERE is_default = 1 LIMIT 1"); + $activeCert = $stmt->fetch(); + } catch (\Throwable) { + $activeCert = null; + } + + $certPath = "/etc/asterisk/keys/asterisk.crt"; + $keyPath = "/etc/asterisk/keys/asterisk.key"; + $caPath = ""; + + if ($activeCert) { + $candidateCert = !empty($activeCert['fullchain_path']) && file_exists($activeCert['fullchain_path']) + ? $activeCert['fullchain_path'] + : ($activeCert['cert_path'] ?? ''); + $candidateKey = $activeCert['key_path'] ?? ''; + $candidateCa = $activeCert['chain_path'] ?? ''; + + if (file_exists($candidateCert) && file_exists($candidateKey)) { + $certPath = $candidateCert; + $keyPath = $candidateKey; + if (!empty($candidateCa) && file_exists($candidateCa)) { + $caPath = $candidateCa; + } + } + } elseif ($leDomain !== '' && file_exists("/etc/letsencrypt/live/{$leDomain}/fullchain.pem")) { + $certPath = "/etc/letsencrypt/live/{$leDomain}/fullchain.pem"; + $keyPath = "/etc/letsencrypt/live/{$leDomain}/privkey.pem"; + } + + $hasTlsCerts = (file_exists($certPath) && file_exists($keyPath)); + + $lines[] = "; --- TRANSPORT TLS (SIGNALISATION SIP SÉCURISÉE PORT {$tlsPort}) ---"; + $verifyClient = (!empty($settings['tls_verify_client']) && (int)$settings['tls_verify_client'] === 1) ? 'yes' : 'no'; + $lines[] = "[transport-tls]"; + $lines[] = "type = transport"; + $lines[] = "protocol = tls"; + $lines[] = "bind = 0.0.0.0:{$tlsPort}"; + $lines[] = "symmetric_transport = yes"; + $lines[] = "cert_file = {$certPath}"; + $lines[] = "priv_key_file = {$keyPath}"; + if ($caPath !== '') { + $lines[] = "ca_list_file = {$caPath}"; + } + $lines[] = "method = tlsv1_2"; + $lines[] = "verify_client = {$verifyClient}"; + $lines[] = "verify_server = no"; + foreach ($localNetworks as $net) { + $net = trim((string)$net); + if ($net !== '') { + $lines[] = "local_net = {$net}"; + } + } + if ($extHost !== '') { + $lines[] = "external_media_address = {$extHost}"; + $lines[] = "external_signaling_address = {$extHost}"; + if ($extTlsPort > 0) { + $lines[] = "external_signaling_port = {$extTlsPort}"; + } + } + $lines[] = "allow_reload = no"; + $lines[] = ""; + + // 3. Extensions (Postes téléphoniques hybrides UDP vs TLS+SRTP) + $lines[] = "; ==========================================================================="; + $lines[] = "; POSTES TÉLÉPHONIQUES (EXTENSIONS)"; + $lines[] = "; ==========================================================================="; + foreach ($extensions as $ext) { + $num = trim((string)$ext['extension']); + $name = str_replace('"', '', trim((string)$ext['name'])); + $secret = trim((string)$ext['secret']); + $wantsTls = ($ext['transport'] === 'tls'); + $transport = ($wantsTls && $hasTlsCerts) ? 'transport-tls' : 'transport-udp'; + $encMode = (int)($ext['encryption'] ?? 0); + if ($wantsTls && $encMode === 0) { + $encMode = 1; // Sécurisation par défaut si transport TLS + } + $codecs = array_filter(array_map('trim', explode(',', (string)$ext['codecs']))); + $maxContacts = max(1, (int)$ext['max_contacts']); + $modeLabel = ($encMode === 2) ? 'SRTP FORCÉ' : (($encMode === 1) ? 'SRTP OPTIONNEL' : 'RTP CLAIR'); + + $sysLang = trim((string)($settings['system_language'] ?? 'fr')) ?: 'fr'; + $hasVm = !empty($ext['voicemail_enabled']); + + $lines[] = "; --- Poste {$num} : {$name} [{$modeLabel}] ---"; + $lines[] = "[{$num}]"; + $lines[] = "type = endpoint"; + $lines[] = "context = from-internal"; + $lines[] = "subscribe_context = from-internal"; + $lines[] = "auth = {$num}-auth"; + $lines[] = "aors = {$num}"; + $lines[] = "callerid = \"{$name}\" <{$num}>"; + $lines[] = "disallow = all"; + foreach ($codecs as $codec) { + $lines[] = "allow = {$codec}"; + } + $lines[] = "dtmf_mode = rfc4733"; + $lines[] = "rtp_symmetric = yes"; + $lines[] = "force_rport = yes"; + $lines[] = "rewrite_contact = yes"; + $lines[] = "direct_media = no"; + if ($wantsTls && $hasTlsCerts) { + // Verrouillage strict du transport TLS : refuse le repli sur UDP + $lines[] = "transport = transport-tls"; + $lines[] = "media_use_received_transport = no"; + } else { + $lines[] = "media_use_received_transport = yes"; + } + $lines[] = "named_call_group = default"; + $lines[] = "named_pickup_group = default"; + $lines[] = "notify_early_inuse_ringing = yes"; + if ($encMode === 2) { + $lines[] = "media_encryption = sdes"; + $lines[] = "media_encryption_optimistic = no"; + $lines[] = "srtp_tag_32 = yes"; + } elseif ($encMode === 1) { + $lines[] = "media_encryption = sdes"; + $lines[] = "media_encryption_optimistic = yes"; + $lines[] = "srtp_tag_32 = yes"; + } else { + $lines[] = "media_encryption = no"; + } + $lines[] = "language = {$sysLang}"; + if ($hasVm) { + $lines[] = "mailboxes = {$num}@default"; + $lines[] = "voicemail_extension = *97"; + } + $lines[] = "moh_suggest = default"; + $lines[] = ""; + + $lines[] = "[{$num}-auth]"; + $lines[] = "type = auth"; + $lines[] = "auth_type = userpass"; + $lines[] = "username = {$num}"; + $lines[] = "password = {$secret}"; + $lines[] = ""; + + $lines[] = "[{$num}]"; + $lines[] = "type = aor"; + $lines[] = "max_contacts = {$maxContacts}"; + $lines[] = "remove_existing = yes"; + $lines[] = "qualify_frequency = 30"; + if ($hasVm) { + $lines[] = "mailboxes = {$num}@default"; + } + $lines[] = ""; + } + + // 4. Trunks SIP (Opérateurs entrants / sortants - Configuration PJSIP complète) + if (!empty($trunks)) { + $lines[] = "; ==========================================================================="; + $lines[] = "; TRUNKS OPÉRATEURS SIP (CONFIGURATION AVANCÉE)"; + $lines[] = "; ==========================================================================="; + foreach ($trunks as $trunk) { + $slug = $this->slugify($trunk['name']); + $host = trim((string)$trunk['host']); + $port = (int)($trunk['port'] ?? 5060); + $outboundProxy = trim((string)($trunk['outbound_proxy'] ?? '')); + $authMode = trim((string)($trunk['auth_mode'] ?? 'userpass')); + $username = trim((string)($trunk['username'] ?? '')); + $authUsername = trim((string)($trunk['auth_username'] ?? '')) ?: $username; + $secret = trim((string)($trunk['secret'] ?? '')); + $trProto = strtolower(trim((string)($trunk['transport'] ?? 'udp'))); + if ($trProto === 'tls' && $hasTlsCerts) { + $transport = 'transport-tls'; + } elseif ($trProto === 'tcp') { + $transport = 'transport-tcp'; + } else { + $transport = 'transport-udp'; + } + + $context = trim((string)($trunk['context'] ?? 'from-external')) ?: 'from-external'; + $fromUser = trim((string)($trunk['from_user'] ?? '')) ?: $username; + $fromDomain = trim((string)($trunk['from_domain'] ?? '')) ?: $host; + $callerId = trim((string)($trunk['outbound_callerid'] ?? '')); + $sendPai = ((int)($trunk['send_pai'] ?? 1) === 1) ? 'yes' : 'no'; + $sendRpid = ((int)($trunk['send_rpid'] ?? 0) === 1) ? 'yes' : 'no'; + $sendDiversion = ((int)($trunk['send_diversion'] ?? 1) === 1) ? 'yes' : 'no'; + $trustInbound = ((int)($trunk['trust_id_inbound'] ?? 1) === 1) ? 'yes' : 'no'; + $dtmfMode = trim((string)($trunk['dtmf_mode'] ?? 'rfc4733')) ?: 'rfc4733'; + $mediaEnc = trim((string)($trunk['media_encryption'] ?? 'no')) ?: 'no'; + $natSym = ((int)($trunk['nat_symmetric'] ?? 1) === 1) ? 'yes' : 'no'; + $directMedia = ((int)($trunk['direct_media'] ?? 0) === 1) ? 'yes' : 'no'; + $qualifyFreq = max(0, (int)($trunk['qualify_frequency'] ?? 60)); + $timers = trim((string)($trunk['timers'] ?? 'yes')) ?: 'yes'; + $language = trim((string)($trunk['language'] ?? 'fr')) ?: 'fr'; + $rtpKeepalive = max(0, (int)($trunk['rtp_keepalive'] ?? 15)); + $t38Udptl = trim((string)($trunk['t38_udptl'] ?? 'no')) ?: 'no'; + $faxDetect = ((int)($trunk['fax_detect'] ?? 0) === 1) ? 'yes' : 'no'; + $inbandProg = ((int)($trunk['inband_progress'] ?? 0) === 1) ? 'yes' : 'no'; + $codecs = array_filter(array_map('trim', explode(',', (string)($trunk['codecs'] ?? 'alaw,ulaw')))); + $hasAuth = ($authMode !== 'ip' && $username !== '' && $secret !== ''); + + $lines[] = "; --- Trunk : {$trunk['name']} ({$host}:{$port} [{$trProto}]) ---"; + $lines[] = "[trunk-{$slug}]"; + $lines[] = "type = endpoint"; + $lines[] = "context = {$context}"; + $lines[] = "transport = {$transport}"; + if ($hasAuth) { + $lines[] = "outbound_auth = trunk-{$slug}-auth"; + } + $lines[] = "aors = trunk-{$slug}"; + if ($outboundProxy !== '') { + $lines[] = "outbound_proxy = {$outboundProxy}"; + } + if ($callerId !== '') { + $lines[] = "callerid = {$callerId}"; + } + $lines[] = "disallow = all"; + foreach ($codecs as $codec) { + $lines[] = "allow = {$codec}"; + } + $lines[] = "dtmf_mode = {$dtmfMode}"; + $lines[] = "rtp_symmetric = {$natSym}"; + $lines[] = "force_rport = {$natSym}"; + $lines[] = "rewrite_contact = {$natSym}"; + $lines[] = "direct_media = {$directMedia}"; + $lines[] = "media_encryption = {$mediaEnc}"; + $lines[] = "send_pai = {$sendPai}"; + $lines[] = "send_rpid = {$sendRpid}"; + $lines[] = "send_diversion = {$sendDiversion}"; + $lines[] = "trust_id_inbound = {$trustInbound}"; + $lines[] = "timers = {$timers}"; + if ($rtpKeepalive > 0) { + $lines[] = "rtp_keepalive = {$rtpKeepalive}"; + } + if ($t38Udptl !== 'no') { + $lines[] = "t38_udptl = {$t38Udptl}"; + $lines[] = "t38_udptl_ec = redundancy"; + } + if ($faxDetect === 'yes') { + $lines[] = "fax_detect = yes"; + } + if ($inbandProg === 'yes') { + $lines[] = "inband_progress = yes"; + } + if ($fromUser !== '') { + $lines[] = "from_user = {$fromUser}"; + } + if ($fromDomain !== '') { + $lines[] = "from_domain = {$fromDomain}"; + } + $lines[] = "language = {$language}"; + + $mohSoundId = (int)($trunk['moh_sound_id'] ?? 0); + $trunkMohClass = ($mohSoundId > 0 && isset($mohMap[$mohSoundId])) + ? 'moh-sound-' . $mohMap[$mohSoundId]['slug'] + : 'default'; + $lines[] = "moh_suggest = {$trunkMohClass}"; + + // Directives PJSIP additionnelles personnalisées sur l'endpoint + $customParams = trim((string)($trunk['custom_endpoint_params'] ?? '')); + if ($customParams !== '') { + foreach (preg_split('/\r?\n/', $customParams) ?: [] as $cpLine) { + $cpLine = trim($cpLine); + if ($cpLine !== '' && !str_starts_with($cpLine, '[') && str_contains($cpLine, '=')) { + $lines[] = $cpLine; + } + } + } + $lines[] = ""; + + // Section AOR du Trunk (nommée trunk-$slug) + $lines[] = "[trunk-{$slug}]"; + $lines[] = "type = aor"; + $lines[] = "contact = sip:{$host}:{$port}"; + $lines[] = "qualify_frequency = {$qualifyFreq}"; + if ($outboundProxy !== '') { + $lines[] = "outbound_proxy = {$outboundProxy}"; + } + $customAor = trim((string)($trunk['custom_aor_params'] ?? '')); + if ($customAor !== '') { + foreach (preg_split('/\r?\n/', $customAor) ?: [] as $caLine) { + $caLine = trim($caLine); + if ($caLine !== '' && !str_starts_with($caLine, '[') && str_contains($caLine, '=')) { + $lines[] = $caLine; + } + } + } + $lines[] = ""; + + // Section Auth du Trunk + if ($hasAuth) { + $lines[] = "[trunk-{$slug}-auth]"; + $lines[] = "type = auth"; + $lines[] = "auth_type = userpass"; + $lines[] = "username = {$authUsername}"; + $lines[] = "password = {$secret}"; + $lines[] = ""; + } + + // Section Registration du Trunk + if ((int)($trunk['register'] ?? 1) === 1 && $hasAuth) { + $serverUri = trim((string)($trunk['server_uri'] ?? '')) ?: "sip:{$host}:{$port}"; + $clientUri = trim((string)($trunk['client_uri'] ?? '')) ?: "sip:{$username}@{$fromDomain}:{$port}"; + $contactUser = trim((string)($trunk['contact_user'] ?? '')); + $retryInterval = max(10, (int)($trunk['retry_interval'] ?? 60)); + $maxRetries = max(0, (int)($trunk['max_retries'] ?? 10000)); + $forbiddenInt = max(0, (int)($trunk['forbidden_retry_interval'] ?? 600)); + $fatalInt = max(0, (int)($trunk['fatal_retry_interval'] ?? 0)); + $expiration = max(60, (int)($trunk['expiration'] ?? 3600)); + $lineSupport = ((int)($trunk['line_support'] ?? 1) === 1); + + $lines[] = "[trunk-{$slug}-reg]"; + $lines[] = "type = registration"; + $lines[] = "transport = {$transport}"; + $lines[] = "outbound_auth = trunk-{$slug}-auth"; + $lines[] = "server_uri = {$serverUri}"; + $lines[] = "client_uri = {$clientUri}"; + if ($contactUser !== '') { + $lines[] = "contact_user = {$contactUser}"; + } + if ($outboundProxy !== '') { + $lines[] = "outbound_proxy = {$outboundProxy}"; + } + $lines[] = "retry_interval = {$retryInterval}"; + $lines[] = "max_retries = {$maxRetries}"; + $lines[] = "forbidden_retry_interval = {$forbiddenInt}"; + $lines[] = "fatal_retry_interval = {$fatalInt}"; + $lines[] = "expiration = {$expiration}"; + $lines[] = "auth_rejection_permanent = no"; + $lines[] = "max_random_initial_delay = 0"; + if ($lineSupport) { + $lines[] = "line = yes"; + $lines[] = "endpoint = trunk-{$slug}"; + } + $customReg = trim((string)($trunk['custom_reg_params'] ?? '')); + if ($customReg !== '') { + foreach (preg_split('/\r?\n/', $customReg) ?: [] as $crLine) { + $crLine = trim($crLine); + if ($crLine !== '' && !str_starts_with($crLine, '[') && str_contains($crLine, '=')) { + $lines[] = $crLine; + } + } + } + $lines[] = ""; + } + + // Section Identify (Reconnaissance d'IPs / sous-réseaux opérateur) + $matchIpsRaw = trim((string)($trunk['match_ips'] ?? '')); + $matchList = $matchIpsRaw !== '' + ? array_filter(array_map('trim', preg_split('/[\s,;]+/', $matchIpsRaw) ?: [])) + : [$host]; + + $lines[] = "[trunk-{$slug}-identify]"; + $lines[] = "type = identify"; + $lines[] = "endpoint = trunk-{$slug}"; + foreach ($matchList as $mIp) { + $lines[] = "match = {$mIp}"; + } + $lines[] = ""; + } + } + + return implode("\n", $lines) . "\n"; + } + + /** + * Génère le contenu complet de /etc/asterisk/extensions.custom.conf (Dialplan) + */ + public function generateExtensionsConf(): string + { + $settings = $this->db->query('SELECT * FROM settings WHERE id = 1')->fetch() ?: []; + $sysLang = trim((string)($settings['system_language'] ?? 'fr')) ?: 'fr'; + $extensions = $this->db->query('SELECT * FROM extensions WHERE enabled = 1 ORDER BY extension ASC')->fetchAll(); + $groups = $this->db->query('SELECT * FROM groups ORDER BY extension ASC')->fetchAll(); + $extGroups = $this->db->query('SELECT * FROM extension_groups ORDER BY id ASC')->fetchAll(); + $trunks = $this->db->query('SELECT * FROM trunks WHERE enabled = 1 ORDER BY id ASC')->fetchAll(); + $ivrs = $this->db->query('SELECT * FROM ivrs ORDER BY name ASC')->fetchAll(); + $outboundRoutes = $this->db->query('SELECT * FROM outbound_routes WHERE enabled = 1 ORDER BY priority ASC, id ASC')->fetchAll(); + $mohSounds = $this->db->query("SELECT id, slug, name FROM sounds WHERE usage_type = 'moh'")->fetchAll(); + $mohSoundsMap = []; + foreach ($mohSounds as $ms) { + $mohSoundsMap[(int)$ms['id']] = $ms; + } + + $date = date('Y-m-d H:i:s'); + + $lines = []; + $lines[] = "; ==========================================================================="; + $lines[] = "; DIALPLAN GÉNÉRÉ AUTOMATIQUEMENT PAR NEXUSPBX ($date)"; + $lines[] = "; NE PAS MODIFIER MANUELLEMENT - SOURCE UNIQUE DE VÉRITÉ : SQLITE"; + $lines[] = "; ==========================================================================="; + $lines[] = ""; + + // 1. Contexte [from-internal] + $lines[] = "[from-internal]"; + + // Récupération des codes de fonctions actifs depuis la base SQLite + $fcMap = []; + try { + $fcRows = $this->db->query("SELECT feature_key, code, enabled FROM feature_codes")->fetchAll(); + foreach ($fcRows as $r) { + if ((int)$r['enabled'] === 1) { + $fcMap[$r['feature_key']] = trim($r['code']); + } + } + } catch (\Throwable) { + $fcMap = []; + } + + // Hints pour supervision d'état & Touches BLF Yealink (Vert=Libre, Rouge clignotant=Sonne, Rouge fixe=Occupé/DND) + $lines[] = "; --- Hints de supervision d'état (Presence, DND & Touches BLF Yealink) ---"; + foreach ($extensions as $ext) { + $num = trim((string)$ext['extension']); + $lines[] = "exten => {$num},hint,PJSIP/{$num}&Custom:DND{$num}"; + $lines[] = "exten => *76{$num},hint,Custom:DND{$num}"; + } + $lines[] = ""; + + // Interception d'appel (Call Pickup) + $blfPrefix = $fcMap['pickup_blf'] ?? '**'; + $groupCode = $fcMap['pickup_group'] ?? '*8'; + + $lines[] = "; --- Interception d'appel (Call Pickup & Touches BLF Yealink) ---"; + if ($blfPrefix !== '') { + $lines[] = "exten => _{$blfPrefix}.,1,NoOp(Interception d'appel dirigee BLF vers \${EXTEN:" . strlen($blfPrefix) . "})"; + $lines[] = " same => n,Pickup(\${EXTEN:" . strlen($blfPrefix) . "}@from-internal)"; + $lines[] = " same => n,Hangup()"; + } + if ($groupCode !== '') { + $lines[] = "exten => _{$groupCode}.,1,NoOp(Interception d'appel dirigee vers \${EXTEN:" . strlen($groupCode) . "})"; + $lines[] = " same => n,Pickup(\${EXTEN:" . strlen($groupCode) . "}@from-internal)"; + $lines[] = " same => n,Hangup()"; + + $lines[] = "exten => {$groupCode},1,NoOp(Interception d'appel de groupe)"; + $lines[] = " same => n,Pickup()"; + $lines[] = " same => n,Hangup()"; + } + $lines[] = ""; + + // Ne Pas Déranger (DND) + $dndOn = $fcMap['dnd_activate'] ?? '*78'; + $dndOff = $fcMap['dnd_deactivate'] ?? '*79'; + $dndTog = $fcMap['dnd_toggle'] ?? '*76'; + + $lines[] = "; --- Mode Ne Pas Déranger (DND) ---"; + if ($dndOn !== '') { + $dndOnLen = strlen($dndOn); + $lines[] = "exten => {$dndOn},1,NoOp(Activation DND pour \${CALLERID(num)})"; + $lines[] = " same => n,Set(DB(DND/\${CALLERID(num)})=YES)"; + $lines[] = " same => n,Set(DEVICE_STATE(Custom:DND\${CALLERID(num)})=BUSY)"; + $lines[] = " same => n,Answer()"; + $lines[] = " same => n,Playback(do-not-disturb)"; + $lines[] = " same => n,Playback(activated)"; + $lines[] = " same => n,Hangup()"; + $lines[] = "exten => _{$dndOn}.,1,NoOp(Activation DND pour extension \${EXTEN:{$dndOnLen}})"; + $lines[] = " same => n,Set(DB(DND/\${EXTEN:{$dndOnLen}})=YES)"; + $lines[] = " same => n,Set(DEVICE_STATE(Custom:DND\${EXTEN:{$dndOnLen}})=BUSY)"; + $lines[] = " same => n,Answer()"; + $lines[] = " same => n,Playback(do-not-disturb)"; + $lines[] = " same => n,Playback(activated)"; + $lines[] = " same => n,Hangup()"; + } + if ($dndOff !== '') { + $dndOffLen = strlen($dndOff); + $lines[] = "exten => {$dndOff},1,NoOp(Desactivation DND pour \${CALLERID(num)})"; + $lines[] = " same => n,Set(DB(DND/\${CALLERID(num)})=)"; + $lines[] = " same => n,Set(DEVICE_STATE(Custom:DND\${CALLERID(num)})=NOT_INUSE)"; + $lines[] = " same => n,Answer()"; + $lines[] = " same => n,Playback(do-not-disturb)"; + $lines[] = " same => n,Playback(de-activated)"; + $lines[] = " same => n,Hangup()"; + $lines[] = "exten => _{$dndOff}.,1,NoOp(Desactivation DND pour extension \${EXTEN:{$dndOffLen}})"; + $lines[] = " same => n,Set(DB(DND/\${EXTEN:{$dndOffLen}})=)"; + $lines[] = " same => n,Set(DEVICE_STATE(Custom:DND\${EXTEN:{$dndOffLen}})=NOT_INUSE)"; + $lines[] = " same => n,Answer()"; + $lines[] = " same => n,Playback(do-not-disturb)"; + $lines[] = " same => n,Playback(de-activated)"; + $lines[] = " same => n,Hangup()"; + } + if ($dndTog !== '') { + $dndTogLen = strlen($dndTog); + $lines[] = "exten => {$dndTog},1,NoOp(Toggle DND pour \${CALLERID(num)})"; + $lines[] = " same => n,GotoIf(\$[\"\${DB(DND/\${CALLERID(num)})}\" = \"YES\"]?dnd_turn_off:dnd_turn_on)"; + $lines[] = " same => n(dnd_turn_on),Set(DB(DND/\${CALLERID(num)})=YES)"; + $lines[] = " same => n,Set(DEVICE_STATE(Custom:DND\${CALLERID(num)})=BUSY)"; + $lines[] = " same => n,Answer()"; + $lines[] = " same => n,Playback(do-not-disturb)"; + $lines[] = " same => n,Playback(activated)"; + $lines[] = " same => n,Hangup()"; + $lines[] = " same => n(dnd_turn_off),Set(DB(DND/\${CALLERID(num)})=)"; + $lines[] = " same => n,Set(DEVICE_STATE(Custom:DND\${CALLERID(num)})=NOT_INUSE)"; + $lines[] = " same => n,Answer()"; + $lines[] = " same => n,Playback(do-not-disturb)"; + $lines[] = " same => n,Playback(de-activated)"; + $lines[] = " same => n,Hangup()"; + $lines[] = "exten => _{$dndTog}.,1,NoOp(Toggle DND pour extension \${EXTEN:{$dndTogLen}})"; + $lines[] = " same => n,GotoIf(\$[\"\${DB(DND/\${EXTEN:{$dndTogLen}})}\" = \"YES\"]?dnd_ext_off:dnd_ext_on)"; + $lines[] = " same => n(dnd_ext_on),Set(DB(DND/\${EXTEN:{$dndTogLen}})=YES)"; + $lines[] = " same => n,Set(DEVICE_STATE(Custom:DND\${EXTEN:{$dndTogLen}})=BUSY)"; + $lines[] = " same => n,Answer()"; + $lines[] = " same => n,Playback(do-not-disturb)"; + $lines[] = " same => n,Playback(activated)"; + $lines[] = " same => n,Hangup()"; + $lines[] = " same => n(dnd_ext_off),Set(DB(DND/\${EXTEN:{$dndTogLen}})=)"; + $lines[] = " same => n,Set(DEVICE_STATE(Custom:DND\${EXTEN:{$dndTogLen}})=NOT_INUSE)"; + $lines[] = " same => n,Answer()"; + $lines[] = " same => n,Playback(do-not-disturb)"; + $lines[] = " same => n,Playback(de-activated)"; + $lines[] = " same => n,Hangup()"; + } + $lines[] = ""; + + // Renvois d'appels + $cfOn = $fcMap['cf_activate'] ?? '*72'; + $cfOff = $fcMap['cf_deactivate'] ?? '*73'; + $cfuOn = $fcMap['cfu_activate'] ?? '*52'; + $cfuOff = $fcMap['cfu_deactivate'] ?? '*53'; + + $lines[] = "; --- Gestion des Renvois d'Appels ---"; + if ($cfOn !== '') { + $lines[] = "exten => _{$cfOn}.,1,NoOp(Renvoi inconditionnel vers \${EXTEN:" . strlen($cfOn) . "})"; + $lines[] = " same => n,Answer()"; + $lines[] = " same => n,Wait(1)"; + $lines[] = " same => n,Set(DB(CF/\${CALLERID(num)})=\${EXTEN:" . strlen($cfOn) . "})"; + $lines[] = " same => n,Playback(call-fwd-unconditional)"; + $lines[] = " same => n,Playback(activated)"; + $lines[] = " same => n,Hangup()"; + + $lines[] = "exten => {$cfOn},1,NoOp(Renvoi inconditionnel interactif)"; + $lines[] = " same => n,Answer()"; + $lines[] = " same => n,Wait(1)"; + $lines[] = " same => n,Read(targetNum,vm-enter-num-to-call,15,,,5)"; + $lines[] = " same => n,GotoIf(\$[\"\${targetNum}\" = \"\"]?cf_abort)"; + $lines[] = " same => n,Set(DB(CF/\${CALLERID(num)})=\${targetNum})"; + $lines[] = " same => n,Playback(call-fwd-unconditional)"; + $lines[] = " same => n,Playback(activated)"; + $lines[] = " same => n,Hangup()"; + $lines[] = " same => n(cf_abort),Playback(cancelled)"; + $lines[] = " same => n,Hangup()"; + } + if ($cfOff !== '') { + $lines[] = "exten => {$cfOff},1,NoOp(Desactivation Renvoi inconditionnel)"; + $lines[] = " same => n,Answer()"; + $lines[] = " same => n,Wait(1)"; + $lines[] = " same => n,Set(DB(CF/\${CALLERID(num)})=)"; + $lines[] = " same => n,Playback(call-fwd-unconditional)"; + $lines[] = " same => n,Playback(de-activated)"; + $lines[] = " same => n,Hangup()"; + } + if ($cfuOn !== '') { + $lines[] = "exten => _{$cfuOn}.,1,NoOp(Renvoi indisponible vers \${EXTEN:" . strlen($cfuOn) . "})"; + $lines[] = " same => n,Answer()"; + $lines[] = " same => n,Wait(1)"; + $lines[] = " same => n,Set(DB(CFU/\${CALLERID(num)})=\${EXTEN:" . strlen($cfuOn) . "})"; + $lines[] = " same => n,Playback(call-fwd-no-ans)"; + $lines[] = " same => n,Playback(activated)"; + $lines[] = " same => n,Hangup()"; + + $lines[] = "exten => {$cfuOn},1,NoOp(Renvoi indisponible interactif)"; + $lines[] = " same => n,Answer()"; + $lines[] = " same => n,Wait(1)"; + $lines[] = " same => n,Read(targetNum,vm-enter-num-to-call,15,,,5)"; + $lines[] = " same => n,GotoIf(\$[\"\${targetNum}\" = \"\"]?cfu_abort)"; + $lines[] = " same => n,Set(DB(CFU/\${CALLERID(num)})=\${targetNum})"; + $lines[] = " same => n,Playback(call-fwd-no-ans)"; + $lines[] = " same => n,Playback(activated)"; + $lines[] = " same => n,Hangup()"; + $lines[] = " same => n(cfu_abort),Playback(cancelled)"; + $lines[] = " same => n,Hangup()"; + } + if ($cfuOff !== '') { + $lines[] = "exten => {$cfuOff},1,NoOp(Desactivation Renvoi indisponible)"; + $lines[] = " same => n,Answer()"; + $lines[] = " same => n,Wait(1)"; + $lines[] = " same => n,Set(DB(CFU/\${CALLERID(num)})=)"; + $lines[] = " same => n,Playback(call-fwd-no-ans)"; + $lines[] = " same => n,Playback(de-activated)"; + $lines[] = " same => n,Hangup()"; + } + $lines[] = ""; + + // Sécurité & Liste Noire (Blacklist) + $blAdd = $fcMap['blacklist_add'] ?? '*30'; + $blRem = $fcMap['blacklist_remove'] ?? '*31'; + $blLast = $fcMap['blacklist_last'] ?? '*32'; + + $lines[] = "; --- Sécurité & Filtrage Liste Noire (Blacklist) ---"; + if ($blAdd !== '') { + $lines[] = "exten => _{$blAdd}.,1,NoOp(Ajout a la blacklist \${EXTEN:" . strlen($blAdd) . "})"; + $lines[] = " same => n,Answer()"; + $lines[] = " same => n,Wait(1)"; + $lines[] = " same => n,Set(DB(blacklist/\${EXTEN:" . strlen($blAdd) . "})=1)"; + $lines[] = " same => n,Playback(num-was-successfully)"; + $lines[] = " same => n,Playback(added)"; + $lines[] = " same => n,Hangup()"; + + $lines[] = "exten => {$blAdd},1,NoOp(Ajout a la blacklist interactif)"; + $lines[] = " same => n,Answer()"; + $lines[] = " same => n,Wait(1)"; + $lines[] = " same => n,Read(targetNum,enter-num-blacklist,15,,,5)"; + $lines[] = " same => n,GotoIf(\$[\"\${targetNum}\" = \"\"]?bl_abort)"; + $lines[] = " same => n,Set(DB(blacklist/\${targetNum})=1)"; + $lines[] = " same => n,Playback(num-was-successfully)"; + $lines[] = " same => n,Playback(added)"; + $lines[] = " same => n,Hangup()"; + $lines[] = " same => n(bl_abort),Playback(cancelled)"; + $lines[] = " same => n,Hangup()"; + } + if ($blRem !== '') { + $lines[] = "exten => _{$blRem}.,1,NoOp(Retrait de la blacklist \${EXTEN:" . strlen($blRem) . "})"; + $lines[] = " same => n,Answer()"; + $lines[] = " same => n,Wait(1)"; + $lines[] = " same => n,Set(DB(blacklist/\${EXTEN:" . strlen($blRem) . "})=)"; + $lines[] = " same => n,Playback(num-was-successfully)"; + $lines[] = " same => n,Playback(removed)"; + $lines[] = " same => n,Hangup()"; + + $lines[] = "exten => {$blRem},1,NoOp(Retrait de la blacklist interactif)"; + $lines[] = " same => n,Answer()"; + $lines[] = " same => n,Wait(1)"; + $lines[] = " same => n,Read(targetNum,entr-num-rmv-blklist,15,,,5)"; + $lines[] = " same => n,GotoIf(\$[\"\${targetNum}\" = \"\"]?blrem_abort)"; + $lines[] = " same => n,Set(DB(blacklist/\${targetNum})=)"; + $lines[] = " same => n,Playback(num-was-successfully)"; + $lines[] = " same => n,Playback(removed)"; + $lines[] = " same => n,Hangup()"; + $lines[] = " same => n(blrem_abort),Playback(cancelled)"; + $lines[] = " same => n,Hangup()"; + } + if ($blLast !== '') { + $lines[] = "exten => {$blLast},1,NoOp(Blacklist dernier appelant)"; + $lines[] = " same => n,Answer()"; + $lines[] = " same => n,Wait(1)"; + $lines[] = " same => n,Set(lastCaller=\${DB(LASTCALLER/\${CALLERID(num)})})"; + $lines[] = " same => n,GotoIf(\$[\"\${lastCaller}\" = \"\"]?bllast_abort)"; + $lines[] = " same => n,Set(DB(blacklist/\${lastCaller})=1)"; + $lines[] = " same => n,Playback(num-was-successfully)"; + $lines[] = " same => n,Playback(added)"; + $lines[] = " same => n,Hangup()"; + $lines[] = " same => n(bllast_abort),Playback(cancelled)"; + $lines[] = " same => n,Hangup()"; + } + $lines[] = ""; + + // Outils & Messagerie + $vmDirect = $fcMap['voicemail_direct'] ?? '*97'; + $vmGeneral = $fcMap['voicemail_general'] ?? '*98'; + $spkExten = $fcMap['speak_exten'] ?? '*65'; + $lastCall = $fcMap['last_caller'] ?? '*69'; + $echoCode = $fcMap['echo_test'] ?? '*43'; + $clockCode = $fcMap['speaking_clock'] ?? '*60'; + $mohCode = $fcMap['moh_test'] ?? '*44'; + + $lines[] = "; --- Messagerie Vocale & Utilitaires ---"; + if ($vmDirect !== '') { + $lines[] = "exten => {$vmDirect},1,NoOp(Messagerie Vocale Directe - \${CALLERID(num)})"; + $lines[] = " same => n,Set(CHANNEL(language)={$sysLang})"; + $lines[] = " same => n,Answer()"; + $lines[] = " same => n,Wait(1)"; + $lines[] = " same => n,GotoIf(\$[\"\${VM_INFO(\${CALLERID(num)}@default,exists)}\" = \"1\"]?vm_ok:vm_none)"; + $lines[] = " same => n(vm_ok),Goto(from-vm-direct,\${CALLERID(num)},1)"; + $lines[] = " same => n(vm_none),NoOp(Aucune boite vocale active pour \${CALLERID(num)})"; + $lines[] = " same => n,Playback(vm-incorrect-mailbox)"; + $lines[] = " same => n,Hangup()"; + } + if ($vmGeneral !== '') { + $lines[] = "exten => {$vmGeneral},1,NoOp(Messagerie Vocale Générale)"; + $lines[] = " same => n,Set(CHANNEL(language)={$sysLang})"; + $lines[] = " same => n,Answer()"; + $lines[] = " same => n,Wait(1)"; + $lines[] = " same => n,VoiceMailMain(@default)"; + $lines[] = " same => n,Hangup()"; + } + if ($spkExten !== '') { + $lines[] = "exten => {$spkExten},1,NoOp(Speak your extension)"; + $lines[] = " same => n,Set(CHANNEL(language)={$sysLang})"; + $lines[] = " same => n,Answer()"; + $lines[] = " same => n,Wait(1)"; + $lines[] = " same => n,Playback(your)"; + $lines[] = " same => n,Playback(extension)"; + $lines[] = " same => n,Playback(number)"; + $lines[] = " same => n,Playback(is)"; + $lines[] = " same => n,SayDigits(\${CALLERID(num)})"; + $lines[] = " same => n,Wait(1)"; + $lines[] = " same => n,Hangup()"; + } + if ($lastCall !== '') { + $lines[] = "exten => {$lastCall},1,NoOp(Dernier appelant reçu)"; + $lines[] = " same => n,Set(CHANNEL(language)={$sysLang})"; + $lines[] = " same => n,Answer()"; + $lines[] = " same => n,Wait(1)"; + $lines[] = " same => n,Set(lastCaller=\${DB(LASTCALLER/\${CALLERID(num)})})"; + $lines[] = " same => n,GotoIf(\$[\"\${lastCaller}\" = \"\"]?nolast)"; + $lines[] = " same => n,Playback(telephone-number)"; + $lines[] = " same => n,SayDigits(\${lastCaller})"; + $lines[] = " same => n,Wait(1)"; + $lines[] = " same => n,Goto(from-internal,\${lastCaller},1)"; + $lines[] = " same => n(nolast),Playback(cannot-complete-as-dialed)"; + $lines[] = " same => n,Hangup()"; + } + if ($echoCode !== '') { + $lines[] = "exten => {$echoCode},1,NoOp(Test Echo Audio)"; + $lines[] = " same => n,Set(CHANNEL(language)={$sysLang})"; + $lines[] = " same => n,Answer()"; + $lines[] = " same => n,Wait(1)"; + $lines[] = " same => n,Playback(demo-echotest)"; + $lines[] = " same => n,Echo()"; + $lines[] = " same => n,Hangup()"; + } + if ($clockCode !== '') { + $lines[] = "exten => {$clockCode},1,NoOp(Horloge Parlante / Test Audio)"; + $lines[] = " same => n,Set(CHANNEL(language)={$sysLang})"; + $lines[] = " same => n,Answer()"; + $lines[] = " same => n,Wait(1)"; + $lines[] = " same => n,SayUnixTime()"; + $lines[] = " same => n,Hangup()"; + } + if ($mohCode !== '') { + $lines[] = "exten => {$mohCode},1,NoOp(Test Musique d'Attente MoH par defaut)"; + $lines[] = " same => n,Set(CHANNEL(language)={$sysLang})"; + $lines[] = " same => n,Answer()"; + $lines[] = " same => n,Wait(1)"; + $lines[] = " same => n,MusicOnHold(default)"; + $lines[] = " same => n,Hangup()"; + } + $lines[] = ""; + + // Appels entre postes internes & Gestion Renvois / DND / Voicemail + $lines[] = "; --- Appels entre postes internes & Gestion Renvois / DND / Voicemail ---"; + foreach ($extensions as $ext) { + $num = trim((string)$ext['extension']); + $name = str_replace('"', '', trim((string)$ext['name'])); + $ringSec = max(5, min(120, (int)($ext['ring_time'] ?? 20))); + $hasVm = !empty($ext['voicemail_enabled']); + + $lines[] = "exten => {$num},1,NoOp(Appel interne vers {$num} - {$name})"; + $lines[] = " same => n,Set(CHANNEL(language)={$sysLang})"; + $lines[] = " same => n,Set(DB(LASTCALLER/{$num})=\${CALLERID(num)})"; + $lines[] = " same => n,GotoIf(\$[\"\${DB(DND/{$num})}\" = \"YES\"]?dnd_{$num})"; + $lines[] = " same => n,GotoIf(\$[\"\${DB(CF/{$num})}\" != \"\"]?cf_{$num})"; + $lines[] = " same => n,Dial(PJSIP/{$num},{$ringSec},tTkK)"; + $lines[] = " same => n,GotoIf(\$[\"\${DB(CFU/{$num})}\" != \"\"]?cfu_{$num})"; + if ($hasVm) { + $lines[] = " same => n,GotoIf(\$[\"\${DIALSTATUS}\" = \"BUSY\"]?busy_vm_{$num})"; + $lines[] = " same => n,GotoIf(\$[\"\${DIALSTATUS}\" = \"NOANSWER\" | \"\${DIALSTATUS}\" = \"CHANUNAVAIL\" | \"\${DIALSTATUS}\" = \"CONGESTION\"]?unavail_vm_{$num})"; + $lines[] = " same => n,Hangup()"; + $lines[] = " same => n(busy_vm_{$num}),VoiceMail({$num}@default,b)"; + $lines[] = " same => n,Hangup()"; + $lines[] = " same => n(unavail_vm_{$num}),VoiceMail({$num}@default,u)"; + $lines[] = " same => n,Hangup()"; + } else { + $lines[] = " same => n,Hangup()"; + } + $lines[] = " same => n(dnd_{$num}),NoOp(Poste {$num} en mode Ne Pas Deranger DND)"; + if ($hasVm) { + $lines[] = " same => n,VoiceMail({$num}@default,b)"; + $lines[] = " same => n,Hangup()"; + } else { + $lines[] = " same => n,Playtones(busy)"; + $lines[] = " same => n,Busy(3)"; + $lines[] = " same => n,Hangup()"; + } + $lines[] = " same => n(cf_{$num}),NoOp(Poste {$num} renvoi inconditionnel vers \${DB(CF/{$num})})"; + $lines[] = " same => n,Goto(from-internal,\${DB(CF/{$num})},1)"; + $lines[] = " same => n(cfu_{$num}),NoOp(Poste {$num} indisponible, renvoi vers \${DB(CFU/{$num})})"; + $lines[] = " same => n,Goto(from-internal,\${DB(CFU/{$num})},1)"; + $lines[] = ""; + } + + // Groupes d'appel (Ring Groups) + if (!empty($groups)) { + $lines[] = "; --- Groupes d'appel (Ring Groups) ---"; + foreach ($groups as $grp) { + $gExt = trim((string)$grp['extension']); + $gName = str_replace('"', '', trim((string)$grp['name'])); + $strategy = $grp['strategy']; + $ringTime = max(5, (int)$grp['ring_time']); + $members = json_decode((string)$grp['members'], true) ?: []; + $members = array_values(array_filter(array_map('trim', $members))); + $failover = trim((string)$grp['failover_dest']); + + $lines[] = "exten => {$gExt},1,NoOp(Groupe {$gExt} - {$gName} [{$strategy}])"; + if (empty($members)) { + $lines[] = " same => n,Hangup()"; + } elseif ($strategy === 'hunt') { + // Sonnerie séquentielle poste par poste + foreach ($members as $m) { + $lines[] = " same => n,Dial(PJSIP/{$m},{$ringTime},tT)"; + } + $lines[] = " same => n," . $this->buildDestinationDialplan($failover); + } else { + // ringall (simultané) + $dialStr = implode('&', array_map(fn($m) => "PJSIP/{$m}", $members)); + $lines[] = " same => n,Dial({$dialStr},{$ringTime},tT)"; + $lines[] = " same => n," . $this->buildDestinationDialplan($failover); + } + $lines[] = " same => n,Hangup()"; + $lines[] = ""; + } + } + + // Accès direct aux IVR depuis l'interne via *8 + id + if (!empty($ivrs)) { + $lines[] = "; --- Test direct des SVI/IVR depuis l'interne ---"; + foreach ($ivrs as $ivr) { + $ivrSlug = $this->slugify($ivr['name']); + $testCode = '*8' . str_pad((string)$ivr['id'], 2, '0', STR_PAD_LEFT); + $lines[] = "exten => {$testCode},1,NoOp(Test SVI {$ivr['name']})"; + $lines[] = " same => n,Goto(ivr-{$ivrSlug},s,1)"; + } + $lines[] = ""; + } + + // 1b. Sortie vers l'extérieur : UNIQUEMENT via les Routes Sortantes actives + // Si aucune route sortante active n'existe ou n'autorise le poste, aucun appel externe ne peut sortir ! + if (!empty($outboundRoutes)) { + $lines[] = "; --- Inclusion du moteur de Routes Sortantes (Outbound Routes) ---"; + $lines[] = "include => outbound-allroutes"; + $lines[] = ""; + } else { + $lines[] = "; --- Aucune Route Sortante active : appels externes verrouilles ---"; + $lines[] = "; Creez et activez au moins une Route Sortante dans l'onglet 'Routes Sortantes' pour autoriser les appels vers l'exterieur."; + $lines[] = ""; + } + + // 1b. Contextes [outbound-allroutes] & Sous-routines [sub-outroute-{id}] + if (!empty($outboundRoutes)) { + // Indexation des membres des Groupes d'extensions (Sites / Droits de routes : Nice, Paris...) + $groupMembersMap = []; + foreach ($extGroups as $eg) { + $egId = (string)$eg['id']; + $egName = trim((string)$eg['name']); + $mList = json_decode((string)$eg['members'], true) ?: []; + $mList = array_values(array_filter(array_map('trim', $mList))); + $groupMembersMap[$egId] = $mList; + $groupMembersMap[$egName] = $mList; + } + // Compatibilité avec les ring groups + foreach ($groups as $grp) { + $gExt = trim((string)$grp['extension']); + $gName = trim((string)$grp['name']); + $mList = json_decode((string)$grp['members'], true) ?: []; + $mList = array_values(array_filter(array_map('trim', $mList))); + if (!isset($groupMembersMap[$gExt])) $groupMembersMap[$gExt] = $mList; + if (!isset($groupMembersMap[$gName])) $groupMembersMap[$gName] = $mList; + } + + // Indexation des Trunks actifs + $trunkByName = []; + foreach ($trunks as $t) { + $trunkByName[trim((string)$t['name'])] = $t; + $trunkByName['trunk-' . $this->slugify($t['name'])] = $t; + } + + $patternDispatches = []; + $subroutineBlocks = []; + + foreach ($outboundRoutes as $rIdx => $route) { + $rId = (int)$route['id']; + $rPriority = (int)($route['priority'] ?? ($rIdx + 1)); + $rName = preg_replace('/[^a-zA-Z0-9 _\-]/', '', trim((string)$route['name'])) ?: "Route-{$rId}"; + $rCid = trim((string)($route['outbound_callerid'] ?? '')); + $overrideCid = ((int)($route['override_ext_cid'] ?? 0) === 1); + $rPass = preg_replace('/[^0-9*#]/', '', trim((string)($route['route_password'] ?? ''))); + $rType = trim((string)($route['route_type'] ?? 'normal')); + $permMode = trim((string)($route['permission_mode'] ?? 'all')); + $contNext = ((int)($route['continue_next_route'] ?? 1) === 1); + $congDest = trim((string)($route['congestion_dest'] ?? 'terminate-congestion')) ?: 'terminate-congestion'; + $rMohClass = preg_replace('/[^a-zA-Z0-9_\-]/', '', trim((string)($route['moh_class'] ?? 'default'))) ?: 'default'; + + $trunkSeq = json_decode((string)($route['trunk_sequence_json'] ?? '[]'), true) ?: []; + $dialPats = json_decode((string)($route['dial_patterns_json'] ?? '[]'), true) ?: []; + $allowGrps = json_decode((string)($route['allowed_groups_json'] ?? '[]'), true) ?: []; + $allowExts = json_decode((string)($route['allowed_extensions_json'] ?? '[]'), true) ?: []; + + // Enregistrement des patterns de cette route dans l'ordonnanceur global + foreach ($dialPats as $pat) { + if (!is_array($pat)) { + continue; + } + $prepend = preg_replace('/[^0-9*#+]/', '', trim((string)($pat['prepend'] ?? ''))); + $prefix = preg_replace('/[^0-9*#+]/', '', trim((string)($pat['prefix'] ?? ''))); + $matchRaw = ltrim(trim((string)($pat['match_pattern'] ?? '')), '_'); + $matchRaw = preg_replace('/[^0-9XZN.*#+\[\]\-!]/i', '', $matchRaw); + $patCid = preg_replace('/[^0-9XZN.*#+\[\]\-]/i', '', ltrim(trim((string)($pat['callerid'] ?? '')), '_')); + + if ($matchRaw === '') { + continue; + } + + $combined = $prefix . $matchRaw; + $needsUnderscore = (bool)preg_match('/[XZN\[\].!]/i', $combined); + $astExten = ($needsUnderscore ? '_' : '') . $combined; + + $patternDispatches[$astExten][] = [ + 'route_id' => $rId, + 'route_name' => $rName, + 'priority' => $rPriority, + 'prepend' => $prepend, + 'prefix_len' => strlen($prefix), + 'callerid' => $patCid, + 'cong_dest' => $congDest, + ]; + } + + // Construction de la sous-routine [sub-outroute-{id}] + $sub = []; + $sub[] = "; --- Sous-routine Route Sortante #{$rPriority} : {$rName} (Type: {$rType}) ---"; + $sub[] = "[sub-outroute-{$rId}]"; + $sub[] = "exten => s,1,NoOp(Evaluation Route Sortante #{$rPriority} [{$rName}] -> Numero=\${ARG1} | Poste=\${CALLERID(num)})"; + $sub[] = " same => n,Set(OUTNUM=\${ARG1})"; + $sub[] = " same => n,Set(CHANNEL(musicclass)={$rMohClass})"; + $sub[] = " same => n,Set(__MOHCLASS={$rMohClass})"; + + // 1. Contrôle des droits d'accès (Groupes Autorisés ex: Nice/Paris + Postes Autorisés) + if ($permMode === 'restricted') { + $effectiveExts = []; + foreach ($allowExts as $ae) { + $ae = preg_replace('/[^0-9a-zA-Z_-]/', '', trim((string)$ae)); + if ($ae !== '') { + $effectiveExts[$ae] = true; + } + } + foreach ($allowGrps as $ag) { + $agKey = trim((string)$ag); + foreach ($groupMembersMap[$agKey] ?? [] as $gm) { + $gm = preg_replace('/[^0-9a-zA-Z_-]/', '', trim((string)$gm)); + if ($gm !== '') { + $effectiveExts[$gm] = true; + } + } + } + + $allowedList = array_keys($effectiveExts); + if (empty($allowedList)) { + $sub[] = " same => n,NoOp(Route [{$rName}] restreinte sans aucun poste autorise -> Passage a la route suivante)"; + $sub[] = " same => n,Return()"; + } else { + $regexStr = '^(' . implode('|', $allowedList) . ')$'; + $sub[] = " same => n,GotoIf(\$[\${REGEX(\"{$regexStr}\" \${CALLERID(num)})} = 1]?auth_ok)"; + $sub[] = " same => n,NoOp(Poste \${CALLERID(num)} non autorise sur la route [{$rName}] -> Passage a la route sortante suivante)"; + $sub[] = " same => n,Return()"; + $sub[] = " same => n(auth_ok),NoOp(Droits valides pour le poste \${CALLERID(num)} sur [{$rName}])"; + } + } + + // 2. Mot de passe / Code PIN éventuel sur la route + if ($rPass !== '') { + $sub[] = " same => n,Authenticate({$rPass})"; + } + + // 3. Positionnement du CallerID Sortant de la route + if ($rCid !== '') { + $sub[] = " same => n,Set(CALLERID(all)={$rCid})"; + } + + // 4. Séquence de Trunks avec bascule automatique (Failover) + $validTrunkCount = 0; + foreach ($trunkSeq as $tSeqName) { + $tKey = trim((string)$tSeqName); + if ($tKey === '') { + continue; + } + $tObj = $trunkByName[$tKey] ?? null; + $tSlug = $tObj ? $this->slugify($tObj['name']) : $this->slugify($tKey); + $tFallbackCid = $tObj ? trim((string)($tObj['outbound_callerid'] ?? '')) : ''; + + if ($rCid === '' && $tFallbackCid !== '' && $overrideCid) { + $sub[] = " same => n,Set(CALLERID(all)={$tFallbackCid})"; + } + + $validTrunkCount++; + $sub[] = " same => n,NoOp(Route [{$rName}] - Tentative Trunk #{$validTrunkCount} [trunk-{$tSlug}] vers \${OUTNUM})"; + $sub[] = " same => n,Dial(PJSIP/\${OUTNUM}@trunk-{$tSlug},60,tT)"; + $sub[] = " same => n,GotoIf(\$[\"\${DIALSTATUS}\" = \"ANSWER\" | \"\${DIALSTATUS}\" = \"BUSY\" | \"\${DIALSTATUS}\" = \"NOANSWER\" | \"\${DIALSTATUS}\" = \"CANCEL\"]?call_done)"; + $sub[] = " same => n,NoOp(Trunk [trunk-{$tSlug}] indisponible [\${DIALSTATUS}] -> Passage au Trunk / Route suivant)"; + } + + // 5. Si tous les Trunks de cette route sont indisponibles + $sub[] = " same => n,Set(CALLERID(all)=\${ORIG_CID})"; + if ($contNext) { + $sub[] = " same => n,NoOp(Fin de sequence pour [{$rName}] -> Passage a la route sortante suivante)"; + $sub[] = " same => n,Return()"; + } else { + $sub[] = " same => n," . $this->buildDestinationDialplan($congDest); + $sub[] = " same => n,Hangup()"; + } + $sub[] = " same => n(call_done),Hangup()"; + $sub[] = ""; + + $subroutineBlocks[] = implode("\n", $sub); + } + + // Génération du contexte [outbound-allroutes] + $lines[] = "[outbound-allroutes]"; + foreach ($patternDispatches as $astExten => $dispatches) { + $lines[] = "; --- Masque sortant : {$astExten} ---"; + $lines[] = "exten => {$astExten},1,NoOp(Appel Sortant vers \${EXTEN} depuis le poste \${CALLERID(num)})"; + $lines[] = " same => n,Set(ORIG_CID=\${CALLERID(all)})"; + + $lastCongDest = 'terminate-congestion'; + foreach ($dispatches as $d) { + $rId = $d['route_id']; + $prepend = $d['prepend']; + $prefixLen = (int)$d['prefix_len']; + $patCid = $d['callerid']; + $lastCongDest = $d['cong_dest'] ?: $lastCongDest; + + $numExpr = $prefixLen > 0 ? "{$prepend}\${EXTEN:{$prefixLen}}" : "{$prepend}\${EXTEN}"; + if ($patCid !== '') { + $lines[] = " same => n,GosubIf(\$[\"\${CALLERID(num)}\" = \"{$patCid}\"]?sub-outroute-{$rId},s,1({$numExpr}))"; + } else { + $lines[] = " same => n,Gosub(sub-outroute-{$rId},s,1({$numExpr}))"; + } + } + $lines[] = " same => n," . $this->buildDestinationDialplan($lastCongDest); + $lines[] = " same => n,Hangup()"; + $lines[] = ""; + } + + foreach ($subroutineBlocks as $block) { + $lines[] = $block; + } + } + + // 2. Contexte [app-terminate] (Actions Terminate Call style FreePBX : Occupation, Congestion, Zapateller, Hangup) + $lines[] = "[app-terminate]"; + $lines[] = "exten => busy,1,NoOp(Terminate Call: Mettre definitivement l appelant sur occupation)"; + $lines[] = " same => n,Playtones(busy)"; + $lines[] = " same => n,Busy(20)"; + $lines[] = " same => n,Hangup()"; + $lines[] = ""; + $lines[] = "exten => congestion,1,NoOp(Terminate Call: Tonalite de congestion)"; + $lines[] = " same => n,Playtones(congestion)"; + $lines[] = " same => n,Congestion(20)"; + $lines[] = " same => n,Hangup()"; + $lines[] = ""; + $lines[] = "exten => zapateller,1,NoOp(Terminate Call: Tonalite SIT Anti-Robot)"; + $lines[] = " same => n,Answer()"; + $lines[] = " same => n,Zapateller()"; + $lines[] = " same => n,Playback(ss-noservice)"; + $lines[] = " same => n,Hangup()"; + $lines[] = ""; + $lines[] = "exten => ringforever,1,NoOp(Terminate Call: Sonnerie dans le vide)"; + $lines[] = " same => n,Ringing()"; + $lines[] = " same => n,Wait(300)"; + $lines[] = " same => n,Hangup()"; + $lines[] = ""; + $lines[] = "exten => hangup,1,NoOp(Terminate Call: Raccrocher immediatement)"; + $lines[] = " same => n,Hangup()"; + $lines[] = ""; + + // 2a-bis. Contexte [app-scam-tarpit] (Pièges à Scammers : Musique d'attente en boucle & Fausse voix en boucle infinie) + $lines[] = "; --- Pièges à Scammers & Démarchage (Tarpit Anti-Spam) ---"; + $lines[] = "[app-scam-tarpit]"; + $lines[] = "exten => moh-default,1,NoOp(Tarpit Scammer - Musique par defaut en boucle)"; + $lines[] = " same => n,Answer()"; + $lines[] = " same => n,Wait(1)"; + $lines[] = " same => n,MusicOnHold(default)"; + $lines[] = " same => n,Hangup()"; + $lines[] = ""; + $lines[] = "exten => _moh-.,1,NoOp(Tarpit Scammer - Musique dediee en boucle: \${EXTEN:4})"; + $lines[] = " same => n,Answer()"; + $lines[] = " same => n,Wait(1)"; + $lines[] = " same => n,MusicOnHold(\${EXTEN:4})"; + $lines[] = " same => n,Hangup()"; + $lines[] = ""; + $lines[] = "exten => _loop-.,1,NoOp(Tarpit Scammer - Message/Voix en boucle infinie: \${EXTEN:5})"; + $lines[] = " same => n,Answer()"; + $lines[] = " same => n,Wait(1)"; + $lines[] = " same => n(playloop),Playback(custom/\${EXTEN:5})"; + $lines[] = " same => n,Wait(1)"; + $lines[] = " same => n,Goto(playloop)"; + $lines[] = " same => n,Hangup()"; + $lines[] = ""; + + // 2b. Contexte [from-pstn-toheader] (Extrait le SDA depuis l'en-tête SIP To + Normalise le CallerID +33/0033 en 0X) + $lines[] = "[from-pstn-toheader]"; + $lines[] = "exten => _X.,1,NoOp(Extraction SDA depuis SIP To Header - Appelant=\${CALLERID(num)})"; + $lines[] = " same => n,ExecIf(\$[\"\${CALLERID(num):0:3}\" = \"+33\"]?Set(CALLERID(num)=0\${CALLERID(num):3}))"; + $lines[] = " same => n,ExecIf(\$[\"\${CALLERID(num):0:4}\" = \"0033\"]?Set(CALLERID(num)=0\${CALLERID(num):4}))"; + $lines[] = " same => n,GotoIf(\$[\"\${DB(blacklist/\${CALLERID(num)})}\" != \"\"]?app-blacklisted,s,1)"; + $lines[] = " same => n,Set(DID_NUM=\${CUT(CUT(PJSIP_HEADER(read,To),@,1),:,2)})"; + $lines[] = " same => n,GotoIf(\$[\"\${DID_NUM}\" != \"\"]?from-external,\${DID_NUM},1:from-external,\${EXTEN},1)"; + $lines[] = "exten => s,1,NoOp(Extraction SDA depuis SIP To Header sur s - Appelant=\${CALLERID(num)})"; + $lines[] = " same => n,ExecIf(\$[\"\${CALLERID(num):0:3}\" = \"+33\"]?Set(CALLERID(num)=0\${CALLERID(num):3}))"; + $lines[] = " same => n,ExecIf(\$[\"\${CALLERID(num):0:4}\" = \"0033\"]?Set(CALLERID(num)=0\${CALLERID(num):4}))"; + $lines[] = " same => n,GotoIf(\$[\"\${DB(blacklist/\${CALLERID(num)})}\" != \"\"]?app-blacklisted,s,1)"; + $lines[] = " same => n,Set(DID_NUM=\${CUT(CUT(PJSIP_HEADER(read,To),@,1),:,2)})"; + $lines[] = " same => n,GotoIf(\$[\"\${DID_NUM}\" != \"\"]?from-external,\${DID_NUM},1:from-external,s,1)"; + $lines[] = ""; + + // Contexte pour appels rejetés par la liste noire + $lines[] = "[app-blacklisted]"; + $lines[] = "exten => s,1,NoOp(Appelant \${CALLERID(num)} bloque par la liste noire)"; + $lines[] = " same => n,Answer()"; + $lines[] = " same => n,Wait(1)"; + $lines[] = " same => n,Playtones(busy)"; + $lines[] = " same => n,Busy(3)"; + $lines[] = " same => n,Hangup()"; + $lines[] = ""; + + // 2c. Contexte [from-external] (Routes Entrantes avec filtrage ID Appelant [CallerID] & SDA [DID]) + $lines[] = "[from-external]"; + $hasCatchAllRoute = false; + + foreach ($trunks as $t) { + $mohSoundId = (int)($t['moh_sound_id'] ?? 0); + $trunkMohClass = ($mohSoundId > 0 && isset($mohSoundsMap[$mohSoundId])) + ? 'moh-sound-' . $mohSoundsMap[$mohSoundId]['slug'] + : 'default'; + + $setMohLines = []; + if ($trunkMohClass !== 'default') { + $setMohLines[] = " same => n,Set(__MOHCLASS={$trunkMohClass})"; + $setMohLines[] = " same => n,Set(CHANNEL(musicclass)={$trunkMohClass})"; + } + + $routes = self::normalizeInboundRoutes((string)($t['did_routes_json'] ?? '[]')); + $routeIdx = 0; + foreach ($routes as $rule) { + $routeIdx++; + $cidRaw = trim((string)($rule['cid'] ?? 'Any')); + $didRaw = trim((string)($rule['did'] ?? 'Any')); + $desc = trim((string)($rule['description'] ?? 'Route entrante')); + $dest = trim((string)($rule['destination'] ?? $t['inbound_dest'] ?? 'hangup')); + + $isAnyDid = ($didRaw === '' || strcasecmp($didRaw, 'any') === 0 || $didRaw === '*' || $didRaw === '_X.'); + $isAnyCid = ($cidRaw === '' || strcasecmp($cidRaw, 'any') === 0 || $cidRaw === '*'); + + $didPattern = $isAnyDid ? '_X.' : preg_replace('/[^0-9+XZN._\[\]\-]/', '', $didRaw); + if ($didPattern === '') { + $didPattern = '_X.'; + } + + $timeInfo = !empty($rule['time_condition_enabled']) + ? " [Horaires: {$rule['time_start']}-{$rule['time_finish']} {$rule['weekdays']}]" + : ""; + + if ($isAnyCid) { + if ($didPattern === '_X.') { + if ($hasCatchAllRoute) { + continue; + } + $hasCatchAllRoute = true; + } + $lines[] = "; Inbound Route [{$desc}] ({$t['name']}) : DID={$didPattern} / Appelant=Any{$timeInfo}"; + $lines[] = "exten => {$didPattern},1,NoOp(Inbound [{$desc}] Appelant=\${CALLERID(num)})"; + foreach ($this->buildInboundRouteLines($rule, $setMohLines, $desc, "{$t['id']}_{$routeIdx}_did") as $rl) { + $lines[] = $rl; + } + if ($isAnyDid) { + $lines[] = "exten => s,1,NoOp(Inbound [{$desc}] sur s Appelant=\${CALLERID(num)})"; + foreach ($this->buildInboundRouteLines($rule, $setMohLines, $desc, "{$t['id']}_{$routeIdx}_s") as $rl) { + $lines[] = $rl; + } + } + } else { + $cidClean = preg_replace('/[^0-9+XZN._\[\]\-]/', '', $cidRaw); + if ($cidClean === '') { + continue; + } + $cidVariants = [$cidClean]; + // Si le motif commence par _0 (ex: _0162XXXXXX), couvrir aussi 0033 et +33 automatiquement + if (str_starts_with($cidClean, '_0') && strlen($cidClean) > 2 && $cidClean[2] !== '0') { + $rest = substr($cidClean, 2); + $cidVariants[] = '_0033' . $rest; + $cidVariants[] = '_+33' . $rest; + } + + $lines[] = "; Inbound Route Filtrage Appelant [{$desc}] ({$t['name']}) : DID={$didPattern} / CID={$cidClean}{$timeInfo}"; + $vIdx = 0; + foreach ($cidVariants as $cidVar) { + $vIdx++; + $lines[] = "exten => {$didPattern}/{$cidVar},1,NoOp(Inbound Filter [{$desc}] CID=\${CALLERID(num)})"; + foreach ($this->buildInboundRouteLines($rule, $setMohLines, $desc, "{$t['id']}_{$routeIdx}_cid_{$vIdx}") as $rl) { + $lines[] = $rl; + } + if ($isAnyDid) { + $lines[] = "exten => s/{$cidVar},1,NoOp(Inbound Filter [{$desc}] sur s CID=\${CALLERID(num)})"; + foreach ($this->buildInboundRouteLines($rule, $setMohLines, $desc, "{$t['id']}_{$routeIdx}_scid_{$vIdx}") as $rl) { + $lines[] = $rl; + } + } + } + } + } + } + + if (!$hasCatchAllRoute) { + $defaultDest = !empty($trunks) ? trim((string)$trunks[0]['inbound_dest']) : (!empty($ivrs) ? $ivrs[0]['name'] : 'hangup'); + $lines[] = "; Route entrante par défaut (Fallback Any/Any)"; + $lines[] = "exten => _X.,1,NoOp(Appel entrant externe par defaut vers \${EXTEN} [CID=\${CALLERID(num)}])"; + $lines[] = " same => n," . $this->buildDestinationDialplan($defaultDest); + $lines[] = " same => n,Hangup()"; + $lines[] = ""; + $lines[] = "exten => s,1,NoOp(Appel entrant externe sur s [CID=\${CALLERID(num)}])"; + $lines[] = " same => n," . $this->buildDestinationDialplan($defaultDest); + $lines[] = " same => n,Hangup()"; + } + $lines[] = ""; + + // 3. Contextes SVI / IVR + foreach ($ivrs as $ivr) { + $ivrSlug = $this->slugify($ivr['name']); + $audio = $this->resolveSoundFile($ivr['audio_file'] ?? '', 'demo-instruct'); + $timeout = max(2, min(60, (int)($ivr['timeout'] ?? 10))); + $directDial = !empty($ivr['direct_dial']); + $invalidRetries = max(1, min(10, (int)($ivr['invalid_retries'] ?? 3))); + $invalidSound = $this->resolveSoundFile($ivr['invalid_sound_id'] ?? 0, 'pbx-invalid'); + $invalidDest = trim((string)($ivr['invalid_dest'] ?? 'hangup')) ?: 'hangup'; + $timeoutRetries = max(1, min(10, (int)($ivr['timeout_retries'] ?? 2))); + $timeoutSound = $this->resolveSoundFile($ivr['timeout_sound_id'] ?? 0, ''); + $timeoutDest = trim((string)($ivr['timeout_dest'] ?? 'hangup')) ?: 'hangup'; + $options = json_decode((string)$ivr['options_json'], true) ?: []; + + $lines[] = "; --- Serveur Vocal Interactif : {$ivr['name']} ---"; + $lines[] = "[ivr-{$ivrSlug}]"; + $lines[] = "exten => s,1,NoOp(SVI {$ivr['name']})"; + $lines[] = " same => n,Answer()"; + $lines[] = " same => n,Wait(1)"; + $lines[] = " same => n,Set(IVR_INVALID_COUNT=0)"; + $lines[] = " same => n,Set(IVR_TIMEOUT_COUNT=0)"; + $lines[] = " same => n(loop),Background({$audio})"; + $lines[] = " same => n,WaitExten({$timeout})"; + $lines[] = ""; + + // Numérotation directe des postes internes si activée + if ($directDial && !empty($extensions)) { + $lines[] = "; Numérotation directe des postes autorisée"; + foreach ($extensions as $ext) { + $extNum = trim((string)$ext['extension']); + if ($extNum !== '') { + $lines[] = "exten => {$extNum},1,NoOp(SVI {$ivr['name']} - Numérotation directe poste {$extNum})"; + $lines[] = " same => n,Goto(from-internal,{$extNum},1)"; + } + } + $lines[] = ""; + } + + // Touches DTMF configurées + if (!empty($options)) { + $lines[] = "; Touches DTMF configurées"; + foreach ($options as $digit => $dest) { + $digit = preg_replace('/[^0-9*#]/', '', (string)$digit); + if ($digit === '') { + continue; + } + $lines[] = "exten => {$digit},1,NoOp(SVI {$ivr['name']} - Touche {$digit} -> {$dest})"; + $lines[] = " same => n," . $this->buildDestinationDialplan((string)$dest); + $lines[] = " same => n,Hangup()"; + } + $lines[] = ""; + } + + // Gestion de touche invalide (i) + $lines[] = "; Gestion touche invalide (max {$invalidRetries} tentative" . ($invalidRetries > 1 ? 's' : '') . ")"; + $lines[] = "exten => i,1,NoOp(SVI {$ivr['name']} - Touche invalide pressee)"; + $lines[] = " same => n,Set(IVR_INVALID_COUNT=$[ \${IVR_INVALID_COUNT} + 1 ])"; + $lines[] = " same => n,GotoIf($[ \${IVR_INVALID_COUNT} >= {$invalidRetries} ]?invalid_fail)"; + $lines[] = " same => n,Playback({$invalidSound})"; + $lines[] = " same => n,Goto(s,loop)"; + $lines[] = " same => n(invalid_fail),NoOp(SVI {$ivr['name']} - Echec invalide max atteint)"; + $lines[] = " same => n,Playback({$invalidSound})"; + $lines[] = " same => n," . $this->buildDestinationDialplan($invalidDest); + $lines[] = " same => n,Hangup()"; + $lines[] = ""; + + // Gestion timeout attente (t) + $lines[] = "; Gestion expiration attente (max {$timeoutRetries} tentative" . ($timeoutRetries > 1 ? 's' : '') . ")"; + $lines[] = "exten => t,1,NoOp(SVI {$ivr['name']} - Timeout attente expire)"; + $lines[] = " same => n,Set(IVR_TIMEOUT_COUNT=$[ \${IVR_TIMEOUT_COUNT} + 1 ])"; + $lines[] = " same => n,GotoIf($[ \${IVR_TIMEOUT_COUNT} >= {$timeoutRetries} ]?timeout_fail)"; + if ($timeoutSound !== '') { + $lines[] = " same => n,Playback({$timeoutSound})"; + } + $lines[] = " same => n,Goto(s,loop)"; + $lines[] = " same => n(timeout_fail),NoOp(SVI {$ivr['name']} - Echec timeout max atteint)"; + if ($timeoutSound !== '') { + $lines[] = " same => n,Playback({$timeoutSound})"; + } + $lines[] = " same => n," . $this->buildDestinationDialplan($timeoutDest); + $lines[] = " same => n,Hangup()"; + $lines[] = ""; + } + + // 5. Contexte dédié Accès Sécurisé Boîte Vocale Directe (*97) + $lines[] = "; ==========================================================================="; + $lines[] = "; ACCÈS SÉCURISÉ BOÎTE VOCALE DIRECTE (*97)"; + $lines[] = "; ==========================================================================="; + $lines[] = "[from-vm-direct]"; + foreach ($extensions as $ext) { + $num = trim((string)$ext['extension']); + if (empty($ext['voicemail_enabled'])) { + continue; + } + $forcePin = !empty($ext['force_pin_change']); + $skipPin = !empty($ext['voicemail_skip_pin']); + + // Si premier appel (force_pin_change) OU accès direct sans PIN activé depuis son combiné : + // On appelle VoiceMailMain avec le flag ,s pour ne pas demander de mot de passe à la connexion. + // Asterisk détecte alors automatiquement qu'il s'agit d'un nouvel utilisateur (password == extension) + // et démarre directement l'assistant vocal pour choisir son code PIN et enregistrer ses annonces ! + if ($forcePin || $skipPin) { + $lines[] = "exten => {$num},1,VoiceMailMain({$num}@default,s)"; + $lines[] = " same => n,Hangup()"; + } else { + // Demande le mot de passe PIN configuré + $lines[] = "exten => {$num},1,VoiceMailMain({$num}@default)"; + $lines[] = " same => n,Hangup()"; + } + } + $lines[] = "exten => _X.,1,Playback(vm-incorrect-mailbox)"; + $lines[] = " same => n,Hangup()"; + $lines[] = ""; + + return implode("\n", $lines) . "\n"; + } + + /** + * Sauvegarde les anciens fichiers et écrit atomiquement pjsip.custom.conf et extensions.custom.conf + * avec les droits Debian asterisk:asterisk (0660). + /** + * Génère les classes Musique d'attente (MOH) personnalisées par site/catégorie + */ + public function generateMusiconholdConf(): string + { + $rows = $this->db->query("SELECT * FROM sounds WHERE usage_type = 'moh' ORDER BY id ASC")->fetchAll(); + $bySite = []; + foreach ($rows as $r) { + $site = trim((string)$r['site_category']) ?: 'Global'; + $bySite[$site][] = $r; + } + + // Synchronisation du fichier MOH par défaut dans /var/lib/asterisk/moh/default + $defId = (int)$this->db->query("SELECT default_moh_sound_id FROM settings WHERE id = 1")->fetchColumn(); + if ($defId > 0) { + $stmtDef = $this->db->prepare("SELECT * FROM sounds WHERE id = :id"); + $stmtDef->execute([':id' => $defId]); + $defSound = $stmtDef->fetch(); + if ($defSound) { + $defDir = "/var/lib/asterisk/moh/default"; + if (!is_dir($defDir)) { + @mkdir($defDir, 0775, true); + } + @chown($defDir, 'www-data'); + @chgrp($defDir, 'asterisk'); + @chmod($defDir, 0775); + + // Nettoyage des anciens fichiers pour ne garder QUE la musique par défaut active + foreach (glob("{$defDir}/*") ?: [] as $oldF) { + if (is_file($oldF)) @unlink($oldF); + } + + $srcWav = "/var/lib/asterisk/sounds/custom/{$defSound['slug']}.wav"; + $srcAlaw = "/var/lib/asterisk/sounds/custom/{$defSound['slug']}.alaw"; + if (file_exists($srcWav)) { + $dstW = "{$defDir}/{$defSound['slug']}.wav"; + @copy($srcWav, $dstW); + @chmod($dstW, 0664); + @chown($dstW, 'www-data'); + @chgrp($dstW, 'asterisk'); + } + if (file_exists($srcAlaw)) { + $dstA = "{$defDir}/{$defSound['slug']}.alaw"; + @copy($srcAlaw, $dstA); + @chmod($dstA, 0664); + @chown($dstA, 'www-data'); + @chgrp($dstA, 'asterisk'); + } + } + } + + $lines = []; + $lines[] = "; ==========================================================================="; + $lines[] = "; CLASSES MUSIQUE D'ATTENTE (MOH) GÉNÉRÉES PAR NEXUSPBX (" . date('Y-m-d H:i:s') . ")"; + $lines[] = "; ==========================================================================="; + $lines[] = ""; + + // Classe par défaut Asterisk + $lines[] = "[default]"; + $lines[] = "mode=files"; + $lines[] = "directory=/var/lib/asterisk/moh/default"; + $lines[] = "sort=alpha"; + $lines[] = ""; + + // Classes individuelles pour chaque musique d'attente (assignation spécifique Trunk / Route) + foreach ($rows as $r) { + $soundClass = "moh-sound-{$r['slug']}"; + $soundDir = "/var/lib/asterisk/moh/sound_{$r['slug']}"; + if (!is_dir($soundDir)) { + @mkdir($soundDir, 0775, true); + } + @chown($soundDir, 'www-data'); + @chgrp($soundDir, 'asterisk'); + @chmod($soundDir, 0775); + + $srcWav = "/var/lib/asterisk/sounds/custom/{$r['slug']}.wav"; + $srcAlaw = "/var/lib/asterisk/sounds/custom/{$r['slug']}.alaw"; + if (file_exists($srcWav)) { + $dstW = "{$soundDir}/{$r['slug']}.wav"; + if (!file_exists($dstW) || filesize($dstW) !== filesize($srcWav)) { + @copy($srcWav, $dstW); + @chmod($dstW, 0664); + @chown($dstW, 'www-data'); + @chgrp($dstW, 'asterisk'); + } + } + if (file_exists($srcAlaw)) { + $dstA = "{$soundDir}/{$r['slug']}.alaw"; + if (!file_exists($dstA) || filesize($dstA) !== filesize($srcAlaw)) { + @copy($srcAlaw, $dstA); + @chmod($dstA, 0664); + @chown($dstA, 'www-data'); + @chgrp($dstA, 'asterisk'); + } + } + + $lines[] = "[{$soundClass}]"; + $lines[] = "mode=files"; + $lines[] = "directory={$soundDir}"; + $lines[] = "sort=alpha"; + $lines[] = ""; + } + + foreach ($bySite as $siteName => $sounds) { + $siteSlug = $this->slugify($siteName); + if ($siteSlug === 'global' || $siteSlug === 'default') { + continue; + } + $mohClass = "moh-{$siteSlug}"; + $dir = "/var/lib/asterisk/moh/{$siteSlug}"; + if (!is_dir($dir)) { + @mkdir($dir, 0775, true); + @chown($dir, 'www-data'); + @chgrp($dir, 'asterisk'); + } + $lines[] = "[{$mohClass}]"; + $lines[] = "mode=files"; + $lines[] = "directory={$dir}"; + $lines[] = "sort=alpha"; + $lines[] = ""; + } + + return implode("\n", $lines) . "\n"; + } + + /** + * Génère le fichier de configuration des ports audio / NAT / RTP (/etc/asterisk/rtp.conf) + */ + public function generateRtpConf(): string + { + $settings = $this->db->query('SELECT * FROM settings WHERE id = 1')->fetch() ?: []; + $rtpStart = max(1024, min(65530, (int)($settings['rtp_start'] ?? 10000))); + $rtpEnd = max($rtpStart + 4, min(65535, (int)($settings['rtp_end'] ?? 20000))); + $checksums = !empty($settings['rtp_checksums']) ? 'yes' : 'no'; + $strict = !empty($settings['rtp_strict']) ? 'yes' : 'no'; + $timeout = max(5, (int)($settings['rtp_timeout'] ?? 30)); + $keepalive = max(0, (int)($settings['rtp_keepalive'] ?? 15)); + $date = date('Y-m-d H:i:s'); + + $lines = []; + $lines[] = "; ==========================================================================="; + $lines[] = "; CONFIGURATION DES PORTS FLUX VOIX AUDIO (RTP) - NexusPBX ($date)"; + $lines[] = "; SOURCE UNIQUE DE VÉRITÉ : SQLITE (Paramètres SIP & NAT)"; + $lines[] = "; ==========================================================================="; + $lines[] = "[general]"; + $lines[] = "rtpstart = {$rtpStart}"; + $lines[] = "rtpend = {$rtpEnd}"; + $lines[] = "rtpchecksums = {$checksums}"; + $lines[] = "strictrtp = {$strict}"; + $lines[] = "rtptimeout = {$timeout}"; + $lines[] = "rtpholdtimeout = 300"; + if ($keepalive > 0) { + $lines[] = "rtpkeepalive = {$keepalive}"; + } + $lines[] = ""; + + return implode("\n", $lines) . "\n"; + } + + /** + * Génère le fichier de configuration des touches en cours d'appel (/etc/asterisk/features.conf) + */ + public function generateFeaturesConf(): string + { + $date = date('Y-m-d H:i:s'); + $lines = []; + $lines[] = "; ==========================================================================="; + $lines[] = "; CONFIGURATION DES TOUCHES EN COURS D'APPEL (FEATURES) - NexusPBX ($date)"; + $lines[] = "; ==========================================================================="; + $lines[] = "[general]"; + $lines[] = "featuredigittimeout => 1500"; + $lines[] = "atxfernoanswertimeout => 15"; + $lines[] = "atxferdropcall => no"; + $lines[] = ""; + $lines[] = "[featuremap]"; + $lines[] = "blindxfer => ##"; + $lines[] = "atxfer => *2"; + $lines[] = "disconnect => *0"; + $lines[] = "automixmon => *1"; + $lines[] = ""; + $lines[] = "[applicationmap]"; + $lines[] = ""; + + return implode("\n", $lines) . "\n"; + } + + /** + * Génère le fichier de messagerie vocale (/etc/asterisk/voicemail.custom.conf) + */ + public function generateVoicemailConf(): string + { + $settings = $this->db->query('SELECT * FROM settings WHERE id = 1')->fetch() ?: []; + $extensions = $this->db->query('SELECT * FROM extensions WHERE enabled = 1 ORDER BY CAST(extension AS INTEGER) ASC, extension ASC')->fetchAll(); + $date = date('Y-m-d H:i:s'); + + $lines = []; + $lines[] = "; ==========================================================================="; + $lines[] = "; FICHIER GÉNÉRÉ AUTOMATIQUEMENT PAR NEXUSPBX ({$date})"; + $lines[] = "; MESSAGERIE VOCALE (VOICEMAIL) - CONTEXTE [default]"; + $lines[] = "; ==========================================================================="; + $lines[] = ""; + $lines[] = "[default](+)"; + + foreach ($extensions as $ext) { + if (empty($ext['voicemail_enabled'])) { + continue; + } + $num = trim((string)$ext['extension']); + $name = str_replace([',', '|', '"'], ' ', trim((string)$ext['name'])); + $email = trim((string)($ext['voicemail_email'] ?? '')); + $pin = trim((string)($ext['voicemail_pin'] ?? '')); + $forcePin = !empty($ext['force_pin_change']); + $attach = !empty($ext['voicemail_attach']) ? 'yes' : 'no'; + $delete = !empty($ext['voicemail_delete']) ? 'yes' : 'no'; + + // Si force_pin_change est actif ou PIN vide, mot de passe initial = numéro d'extension + // ce qui déclenche automatiquement le tutoriel de premier appel Asterisk + $spoolBoxDir = "/var/spool/asterisk/voicemail/default/{$num}"; + if ($forcePin || $pin === '') { + $effectivePin = $num; + $options = "attach={$attach}|delete={$delete}|forcename=yes|forcegreetings=yes"; + // Supprimer un éventuel secret.conf antérieur pour forcer Asterisk à réinitialiser la boîte au premier appel + if (file_exists("{$spoolBoxDir}/secret.conf")) { + @unlink("{$spoolBoxDir}/secret.conf"); + } + } else { + $effectivePin = $pin; + $options = "attach={$attach}|delete={$delete}"; + // Mettre à jour secret.conf avec le PIN configuré + if (is_dir($spoolBoxDir)) { + @file_put_contents("{$spoolBoxDir}/secret.conf", $pin . "\n"); + @chown("{$spoolBoxDir}/secret.conf", 'asterisk'); + @chgrp("{$spoolBoxDir}/secret.conf", 'asterisk'); + } + } + + $lines[] = "{$num} => {$effectivePin},{$name},{$email},,{$options}"; + } + $lines[] = ""; + + return implode("\n", $lines) . "\n"; + } + + public function writeAllConfigs(): array + { + if (!is_dir(self::BACKUP_DIR)) { + @mkdir(self::BACKUP_DIR, 0770, true); + } + + $pjsipContent = $this->generatePjsipConf(); + $extensionsContent = $this->generateExtensionsConf(); + $mohContent = $this->generateMusiconholdConf(); + $rtpContent = $this->generateRtpConf(); + $featuresContent = $this->generateFeaturesConf(); + $voicemailContent = $this->generateVoicemailConf(); + + $this->writeAtomicWithPermissions(self::PJSIP_CONF_PATH, $pjsipContent); + $this->writeAtomicWithPermissions(self::EXTENSIONS_CONF_PATH, $extensionsContent); + $this->writeAtomicWithPermissions(self::MOH_CONF_PATH, $mohContent); + $this->writeAtomicWithPermissions(self::RTP_CONF_PATH, $rtpContent); + $this->writeAtomicWithPermissions(self::FEATURES_CONF_PATH, $featuresContent); + $this->writeAtomicWithPermissions(self::VOICEMAIL_CONF_PATH, $voicemailContent); + + // S'assurer que pjsip.conf et extensions.conf incluent bien les fichiers .custom.conf + SystemChecker::ensureAsteriskIncludes(); + + $this->db->exec("UPDATE settings SET last_applied_at = CURRENT_TIMESTAMP WHERE id = 1"); + + return [ + 'pjsip_path' => self::PJSIP_CONF_PATH, + 'extensions_path' => self::EXTENSIONS_CONF_PATH, + 'moh_path' => self::MOH_CONF_PATH, + 'rtp_path' => self::RTP_CONF_PATH, + 'features_path' => self::FEATURES_CONF_PATH, + 'voicemail_path' => self::VOICEMAIL_CONF_PATH, + 'pjsip_bytes' => strlen($pjsipContent), + 'extensions_bytes'=> strlen($extensionsContent), + 'moh_bytes' => strlen($mohContent), + 'rtp_bytes' => strlen($rtpContent), + 'features_bytes' => strlen($featuresContent), + 'voicemail_bytes' => strlen($voicemailContent), + ]; + } + + private function writeAtomicWithPermissions(string $targetPath, string $content): void + { + $dir = dirname($targetPath); + if (!is_dir($dir)) { + throw new RuntimeException("Le dossier {$dir} n'existe pas sur ce serveur Debian."); + } + + // Sauvegarde horodatée si le fichier existe déjà + if (file_exists($targetPath)) { + $stamp = date('Ymd_His'); + $base = basename($targetPath); + @copy($targetPath, self::BACKUP_DIR . "/{$base}.{$stamp}.bak"); + } + + $tmpPath = $targetPath . '.tmp.' . getmypid(); + if (@file_put_contents($tmpPath, $content, LOCK_EX) === false) { + throw new RuntimeException("Impossible d'écrire dans {$targetPath}. Vérifiez que www-data fait partie du groupe asterisk et que /etc/asterisk est accessible en écriture de groupe (chmod 2775)."); + } + + @chgrp($tmpPath, 'asterisk'); + @chmod($tmpPath, 0660); + + if (!@rename($tmpPath, $targetPath)) { + @unlink($tmpPath); + throw new RuntimeException("Échec du remplacement atomique de {$targetPath}."); + } + } + + public static function normalizeInboundRoutes(string $json): array + { + $decoded = json_decode($json, true); + if (!is_array($decoded) || empty($decoded)) { + return []; + } + + // Format liste structurée : [ ['cid' => '...', 'did' => '...', 'description' => '...', 'destination' => '...'], ... ] + if (array_is_list($decoded)) { + $out = []; + foreach ($decoded as $item) { + if (!is_array($item)) { + continue; + } + $cid = trim((string)($item['cid'] ?? 'Any')) ?: 'Any'; + $did = trim((string)($item['did'] ?? 'Any')) ?: 'Any'; + $desc = trim((string)($item['description'] ?? '')); + $dest = trim((string)($item['destination'] ?? '')); + if ($dest === '') { + continue; + } + + $timeCondition = !empty($item['time_condition_enabled']); + $timeStart = preg_match('/^\d{2}:\d{2}$/', (string)($item['time_start'] ?? '')) ? (string)$item['time_start'] : '08:30'; + $timeFinish = preg_match('/^\d{2}:\d{2}$/', (string)($item['time_finish'] ?? '')) ? (string)$item['time_finish'] : '18:00'; + $weekdays = trim((string)($item['weekdays'] ?? 'mon-fri')) ?: 'mon-fri'; + $destClosed = trim((string)($item['time_dest_closed'] ?? 'terminate-hangup')) ?: 'terminate-hangup'; + + $out[] = [ + 'cid' => $cid, + 'did' => $did, + 'description' => $desc ?: ($cid !== 'Any' ? "Filtre {$cid}" : "Route {$did}"), + 'destination' => $dest, + 'time_condition_enabled' => (int)$timeCondition, + 'time_start' => $timeStart, + 'time_finish' => $timeFinish, + 'weekdays' => $weekdays, + 'time_dest_closed' => $destClosed, + ]; + } + return $out; + } + + // Rétrocompatibilité ancien format clé/valeur : { "0123456789": "ext-101" } + $out = []; + foreach ($decoded as $did => $dest) { + $d = trim((string)$did); + $v = trim((string)$dest); + if ($d !== '' && $v !== '') { + $out[] = [ + 'cid' => 'Any', + 'did' => $d, + 'description' => "SDA {$d}", + 'destination' => $v, + 'time_condition_enabled' => 0, + 'time_start' => '08:30', + 'time_finish' => '18:00', + 'weekdays' => 'mon-fri', + 'time_dest_closed' => 'terminate-hangup', + ]; + } + } + return $out; + } + + private function buildInboundRouteLines(array $rule, array $setMohLines, string $desc, string $tagId): array + { + $dest = trim((string)($rule['destination'] ?? 'hangup')); + $timeEnabled = !empty($rule['time_condition_enabled']); + $timeStart = trim((string)($rule['time_start'] ?? '08:30')); + $timeFinish = trim((string)($rule['time_finish'] ?? '18:00')); + $weekdays = trim((string)($rule['weekdays'] ?? 'mon-fri')); + $destClosed = trim((string)($rule['time_dest_closed'] ?? 'terminate-hangup')); + $timeRange = "{$timeStart}-{$timeFinish}"; + + $out = []; + foreach ($setMohLines as $sml) { + $out[] = $sml; + } + + if ($timeEnabled) { + $tag = "tc_{$tagId}"; + $out[] = " same => n,GotoIfTime({$timeRange},{$weekdays},*,*?{$tag}_open)"; + $out[] = " same => n,NoOp(Inbound [{$desc}] - Fermé / Hors horaires [{$timeRange}/{$weekdays}] -> {$destClosed})"; + $out[] = " same => n," . $this->buildDestinationDialplan($destClosed); + $out[] = " same => n,Hangup()"; + $out[] = " same => n({$tag}_open),NoOp(Inbound [{$desc}] - Ouvert [{$timeRange}/{$weekdays}] -> {$dest})"; + $out[] = " same => n," . $this->buildDestinationDialplan($dest); + $out[] = " same => n,Hangup()"; + } else { + $out[] = " same => n," . $this->buildDestinationDialplan($dest); + $out[] = " same => n,Hangup()"; + } + + return $out; + } + + private function buildDestinationDialplan(string $dest): string + { + $dest = trim($dest); + if ($dest === '' || $dest === 'hangup' || $dest === 'terminate-hangup') { + return 'Goto(app-terminate,hangup,1)'; + } + if ($dest === 'busy' || $dest === 'terminate-busy') { + return 'Goto(app-terminate,busy,1)'; + } + if ($dest === 'congestion' || $dest === 'terminate-congestion') { + return 'Goto(app-terminate,congestion,1)'; + } + if ($dest === 'zapateller' || $dest === 'terminate-zapateller') { + return 'Goto(app-terminate,zapateller,1)'; + } + if ($dest === 'ringforever' || $dest === 'terminate-ringforever') { + return 'Goto(app-terminate,ringforever,1)'; + } + if ($dest === 'moh-default') { + return 'Goto(app-scam-tarpit,moh-default,1)'; + } + if (str_starts_with($dest, 'moh-')) { + $mohId = substr($dest, 4); + $stmt = $this->db->prepare('SELECT slug FROM sounds WHERE id = :id'); + $stmt->execute([':id' => (int)$mohId]); + $slug = $stmt->fetchColumn(); + $mohClass = $slug ? "moh-sound-{$slug}" : 'default'; + return "Goto(app-scam-tarpit,moh-{$mohClass},1)"; + } + if (str_starts_with($dest, 'sound-loop-')) { + $sndId = substr($dest, 11); + $stmt = $this->db->prepare('SELECT slug FROM sounds WHERE id = :id'); + $stmt->execute([':id' => (int)$sndId]); + $slug = $stmt->fetchColumn(); + if ($slug) { + return "Goto(app-scam-tarpit,loop-{$slug},1)"; + } + return 'Goto(app-terminate,hangup,1)'; + } + if (str_starts_with($dest, 'sound-')) { + $snd = $this->resolveSoundFile(substr($dest, 6)); + if ($snd !== '') { + return "Playback({$snd})"; + } + return 'Goto(app-terminate,hangup,1)'; + } + if (str_starts_with($dest, 'custom-') || str_starts_with($dest, 'mobile-') || str_starts_with($dest, 'external-')) { + $num = preg_replace('/^(custom|mobile|external)-/', '', $dest); + return "Goto(from-internal,{$num},1)"; + } + if (preg_match('/^\d+$/', $dest)) { + return "Goto(from-internal,{$dest},1)"; + } + if (str_starts_with($dest, 'ext-')) { + $ext = substr($dest, 4); + return "Goto(from-internal,{$ext},1)"; + } + if (str_starts_with($dest, 'grp-')) { + $grp = substr($dest, 4); + return "Goto(from-internal,{$grp},1)"; + } + if (str_starts_with($dest, 'ivr-')) { + $slug = $this->slugify($dest); + return "Goto(ivr-{$slug},s,1)"; + } + // Si c'est directement le nom d'un IVR + $slug = $this->slugify($dest); + return "Goto(ivr-{$slug},s,1)"; + } + + private function resolveSoundFile($soundRef, string $fallback = ''): string + { + if (empty($soundRef)) { + return $fallback; + } + if (is_numeric($soundRef)) { + $stmt = $this->db->prepare('SELECT slug FROM sounds WHERE id = :id'); + $stmt->execute([':id' => (int)$soundRef]); + $slug = $stmt->fetchColumn(); + return $slug ? "custom/{$slug}" : $fallback; + } + $ref = trim((string)$soundRef); + if (str_starts_with($ref, 'sound-')) { + return $this->resolveSoundFile(substr($ref, 6), $fallback); + } + if (!str_contains($ref, '/') && !in_array($ref, ['demo-instruct', 'demo-echotest', 'pbx-invalid', 'hello-world', 'beep', 'silence/1', 'vm-goodbye'])) { + $stmt = $this->db->prepare('SELECT slug FROM sounds WHERE slug = :slug OR name = :name'); + $stmt->execute([':slug' => $ref, ':name' => $ref]); + $slug = $stmt->fetchColumn(); + if ($slug) { + return "custom/{$slug}"; + } + } + return $ref ?: $fallback; + } + + private function slugify(string $text): string + { + $slug = strtolower(trim($text)); + $slug = preg_replace('/^ivr-/', '', $slug); + $slug = preg_replace('/[^a-z0-9]+/', '-', $slug); + return trim($slug, '-') ?: 'default'; + } +} + diff --git a/src/Database.php b/src/Database.php new file mode 100644 index 0000000..cc59a66 --- /dev/null +++ b/src/Database.php @@ -0,0 +1,871 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ]); + + self::$instance->exec('PRAGMA foreign_keys = ON;'); + self::$instance->exec('PRAGMA journal_mode = WAL;'); + self::$instance->exec('PRAGMA busy_timeout = 5000;'); + + self::initializeSchema(); + + if ($isNew) { + @chmod(self::DB_PATH, 0660); + } + } + + return self::$instance; + } + + public static function initializeSchema(): void + { + $db = self::$instance; + if (!$db) { + return; + } + + $schema = <<exec($schema); + self::migrateSettingsTable($db); + self::migrateExtensionsTable($db); + self::migrateTrunksTable($db); + self::migrateOutboundRoutesTable($db); + self::migrateExtensionGroupsTable($db); + self::migrateSoundsTable($db); + self::migrateSoundSitesTable($db); + self::migrateIvrsTable($db); + self::migrateCertificatesTable($db); + self::migrateFeatureCodesTable($db); + + // Création du compte admin par défaut (admin / admin123) s'il n'existe aucun utilisateur + $userCount = (int)$db->query('SELECT COUNT(*) FROM users')->fetchColumn(); + if ($userCount === 0) { + $stmt = $db->prepare('INSERT INTO users (username, password_hash) VALUES (:u, :p)'); + $stmt->execute([ + ':u' => 'admin', + ':p' => password_hash('admin123', PASSWORD_DEFAULT), + ]); + } + + // Données de démonstration initiales si la table extensions est vide + $extCount = (int)$db->query('SELECT COUNT(*) FROM extensions')->fetchColumn(); + if ($extCount === 0) { + $stmtExt = $db->prepare(' + INSERT INTO extensions (extension, name, secret, transport, encryption, codecs, max_contacts, enabled) + VALUES (:ext, :name, :secret, :transport, :encryption, :codecs, :max_contacts, 1) + '); + $stmtExt->execute([ + ':ext' => '101', + ':name' => 'Accueil Bureau (LAN)', + ':secret' => 'LanPass101!Sip', + ':transport' => 'udp', + ':encryption' => 0, + ':codecs' => 'alaw,ulaw,g722', + ':max_contacts' => 2, + ]); + $stmtExt->execute([ + ':ext' => '102', + ':name' => 'Direction Nomade (TLS/SRTP)', + ':secret' => 'TlsSecure102!Sip', + ':transport' => 'tls', + ':encryption' => 1, + ':codecs' => 'opus,g722,alaw', + ':max_contacts' => 3, + ]); + + $stmtGrp = $db->prepare(' + INSERT INTO groups (extension, name, strategy, ring_time, members, failover_dest) + VALUES (:ext, :name, :strategy, :ring_time, :members, :failover) + '); + $stmtGrp->execute([ + ':ext' => '600', + ':name' => 'Support Général', + ':strategy' => 'ringall', + ':ring_time' => 25, + ':members' => json_encode(['101', '102']), + ':failover' => 'hangup', + ]); + + $stmtIvr = $db->prepare(' + INSERT INTO ivrs (name, audio_file, timeout, options_json) + VALUES (:name, :audio, :timeout, :options) + '); + $stmtIvr->execute([ + ':name' => 'ivr-accueil', + ':audio' => 'custom/bienvenue-entreprise', + ':timeout' => 5, + ':options' => json_encode([ + '1' => 'ext-101', + '2' => 'grp-600', + '0' => 'ext-102', + ]), + ]); + } + } + + private static function migrateSettingsTable(PDO $db): void + { + $cols = $db->query('PRAGMA table_info(settings)')->fetchAll(); + $existing = array_column($cols, 'name'); + + if (!in_array('company_name', $existing, true)) { + $db->exec("ALTER TABLE settings ADD COLUMN company_name VARCHAR(128) NOT NULL DEFAULT ''"); + } + if (!in_array('company_logo', $existing, true)) { + $db->exec("ALTER TABLE settings ADD COLUMN company_logo TEXT NOT NULL DEFAULT ''"); + } + if (!in_array('default_moh_sound_id', $existing, true)) { + $db->exec("ALTER TABLE settings ADD COLUMN default_moh_sound_id INTEGER DEFAULT 2"); + } + if (!in_array('tls_verify_client', $existing, true)) { + $db->exec("ALTER TABLE settings ADD COLUMN tls_verify_client INTEGER NOT NULL DEFAULT 0"); + } + + $extraSettings = [ + 'external_udp_port' => "INTEGER NOT NULL DEFAULT 0", + 'external_tls_port' => "INTEGER NOT NULL DEFAULT 0", + 'local_networks_json' => "TEXT NOT NULL DEFAULT '[\"10.10.0.0/16\",\"192.168.1.0/24\"]'", + 'rtp_start' => "INTEGER NOT NULL DEFAULT 10000", + 'rtp_end' => "INTEGER NOT NULL DEFAULT 20000", + 'rtp_checksums' => "INTEGER NOT NULL DEFAULT 1", + 'rtp_strict' => "INTEGER NOT NULL DEFAULT 1", + 'rtp_timeout' => "INTEGER NOT NULL DEFAULT 30", + 'rtp_keepalive' => "INTEGER NOT NULL DEFAULT 15", + 'allow_anonymous_sip' => "INTEGER NOT NULL DEFAULT 0", + 'system_language' => "VARCHAR(16) NOT NULL DEFAULT 'fr'", + 'backup_auto_enabled' => "INTEGER NOT NULL DEFAULT 0", + 'backup_frequency' => "VARCHAR(32) NOT NULL DEFAULT 'daily'", + 'backup_retention' => "INTEGER NOT NULL DEFAULT 7", + 'backup_storage_type' => "VARCHAR(16) NOT NULL DEFAULT 'local'", + 'backup_ssh_host' => "VARCHAR(128) NOT NULL DEFAULT ''", + 'backup_ssh_port' => "INTEGER NOT NULL DEFAULT 22", + 'backup_ssh_user' => "VARCHAR(64) NOT NULL DEFAULT ''", + 'backup_ssh_auth_type'=> "VARCHAR(16) NOT NULL DEFAULT 'password'", + 'backup_ssh_password' => "TEXT NOT NULL DEFAULT ''", + 'backup_ssh_remote_path' => "VARCHAR(255) NOT NULL DEFAULT '/backups/nexus'", + 'backup_last_run' => "VARCHAR(32) DEFAULT NULL", + 'backup_last_status' => "TEXT NOT NULL DEFAULT ''", + 'firewall_enabled' => "INTEGER NOT NULL DEFAULT 1", + 'admin_https_port' => "INTEGER NOT NULL DEFAULT 8443", + 'certbot_strict_port80' => "INTEGER NOT NULL DEFAULT 0", + 'fail2ban_sip_maxretry' => "INTEGER NOT NULL DEFAULT 3", + 'fail2ban_sip_findtime' => "INTEGER NOT NULL DEFAULT 600", + 'fail2ban_sip_bantime' => "INTEGER NOT NULL DEFAULT 3600", + 'fail2ban_web_maxretry' => "INTEGER NOT NULL DEFAULT 3", + 'fail2ban_web_bantime' => "INTEGER NOT NULL DEFAULT 3600", + 'fail2ban_whitelist_ips' => "TEXT NOT NULL DEFAULT '127.0.0.1/8 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 82.122.84.60'", + ]; + foreach ($extraSettings as $colName => $colDef) { + if (!in_array($colName, $existing, true)) { + $db->exec("ALTER TABLE settings ADD COLUMN {$colName} {$colDef}"); + } + } + } + + private static function migrateExtensionsTable(PDO $db): void + { + $sql = $db->query("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'extensions'")->fetchColumn(); + if ($sql && str_contains($sql, "CHECK (encryption IN (0, 1))")) { + $db->beginTransaction(); + try { + $db->exec("CREATE TABLE extensions_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + extension VARCHAR(16) NOT NULL UNIQUE, + name VARCHAR(64) NOT NULL, + secret VARCHAR(128) NOT NULL, + transport VARCHAR(8) NOT NULL DEFAULT 'udp' CHECK (transport IN ('udp', 'tls')), + encryption INTEGER NOT NULL DEFAULT 0 CHECK (encryption IN (0, 1, 2)), + codecs VARCHAR(64) NOT NULL DEFAULT 'alaw,ulaw,opus', + max_contacts INTEGER NOT NULL DEFAULT 2, + enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)), + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + )"); + $db->exec("INSERT INTO extensions_new SELECT * FROM extensions"); + $db->exec("DROP TABLE extensions"); + $db->exec("ALTER TABLE extensions_new RENAME TO extensions"); + $db->commit(); + } catch (\Throwable $e) { + $db->rollBack(); + throw $e; + } + } + + $cols = $db->query('PRAGMA table_info(extensions)')->fetchAll(); + $existing = array_column($cols, 'name'); + + $extraExtensionCols = [ + 'voicemail_enabled' => "INTEGER NOT NULL DEFAULT 0", + 'voicemail_pin' => "VARCHAR(32) NOT NULL DEFAULT ''", + 'force_pin_change' => "INTEGER NOT NULL DEFAULT 0", + 'voicemail_skip_pin' => "INTEGER NOT NULL DEFAULT 0", + 'voicemail_email' => "VARCHAR(128) NOT NULL DEFAULT ''", + 'voicemail_attach' => "INTEGER NOT NULL DEFAULT 1", + 'voicemail_delete' => "INTEGER NOT NULL DEFAULT 0", + 'ring_time' => "INTEGER NOT NULL DEFAULT 20", + ]; + foreach ($extraExtensionCols as $colName => $colDef) { + if (!in_array($colName, $existing, true)) { + $db->exec("ALTER TABLE extensions ADD COLUMN {$colName} {$colDef}"); + } + } + } + + private static function migrateTrunksTable(PDO $db): void + { + $cols = $db->query('PRAGMA table_info(trunks)')->fetchAll(); + $existing = array_column($cols, 'name'); + + if (!in_array('outbound_proxy', $existing, true)) { + // Migration sans perte de la table trunks vers le schéma PJSIP complet (avec support UDP/TCP/TLS) + $db->beginTransaction(); + try { + $db->exec('ALTER TABLE trunks RENAME TO trunks_old'); + $db->exec(" + CREATE TABLE trunks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name VARCHAR(64) NOT NULL UNIQUE, + host VARCHAR(128) NOT NULL, + port INTEGER NOT NULL DEFAULT 5060, + outbound_proxy VARCHAR(128) NOT NULL DEFAULT '', + transport VARCHAR(8) NOT NULL DEFAULT 'udp', + auth_mode VARCHAR(16) NOT NULL DEFAULT 'userpass', + username VARCHAR(64) NOT NULL DEFAULT '', + auth_username VARCHAR(64) NOT NULL DEFAULT '', + secret VARCHAR(128) NOT NULL DEFAULT '', + register INTEGER NOT NULL DEFAULT 1 CHECK (register IN (0, 1)), + server_uri VARCHAR(192) NOT NULL DEFAULT '', + client_uri VARCHAR(192) NOT NULL DEFAULT '', + contact_user VARCHAR(64) NOT NULL DEFAULT '', + expiration INTEGER NOT NULL DEFAULT 3600, + retry_interval INTEGER NOT NULL DEFAULT 60, + line_support INTEGER NOT NULL DEFAULT 1 CHECK (line_support IN (0, 1)), + from_user VARCHAR(64) NOT NULL DEFAULT '', + from_domain VARCHAR(128) NOT NULL DEFAULT '', + outbound_callerid VARCHAR(128) NOT NULL DEFAULT '', + send_pai INTEGER NOT NULL DEFAULT 1 CHECK (send_pai IN (0, 1)), + send_rpid INTEGER NOT NULL DEFAULT 0 CHECK (send_rpid IN (0, 1)), + trust_id_inbound INTEGER NOT NULL DEFAULT 1 CHECK (trust_id_inbound IN (0, 1)), + codecs VARCHAR(64) NOT NULL DEFAULT 'alaw,ulaw,g722', + dtmf_mode VARCHAR(16) NOT NULL DEFAULT 'rfc4733', + media_encryption VARCHAR(16) NOT NULL DEFAULT 'no', + nat_symmetric INTEGER NOT NULL DEFAULT 1 CHECK (nat_symmetric IN (0, 1)), + direct_media INTEGER NOT NULL DEFAULT 0 CHECK (direct_media IN (0, 1)), + qualify_frequency INTEGER NOT NULL DEFAULT 60, + max_channels INTEGER NOT NULL DEFAULT 0, + match_ips TEXT NOT NULL DEFAULT '', + context VARCHAR(64) NOT NULL DEFAULT 'from-external', + inbound_dest VARCHAR(64) NOT NULL DEFAULT 'ivr-accueil', + did_routes_json TEXT NOT NULL DEFAULT '{}', + outbound_prefix VARCHAR(32) NOT NULL DEFAULT '', + custom_endpoint_params TEXT NOT NULL DEFAULT '', + enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)), + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + "); + $db->exec(" + INSERT INTO trunks (id, name, host, port, username, secret, transport, register, codecs, inbound_dest, enabled, created_at) + SELECT id, name, host, port, username, secret, transport, register, codecs, inbound_dest, enabled, created_at + FROM trunks_old + "); + $db->exec('DROP TABLE trunks_old'); + $db->commit(); + } catch (\Throwable $e) { + $db->rollBack(); + throw $e; + } + $cols = $db->query('PRAGMA table_info(trunks)')->fetchAll(); + $existing = array_column($cols, 'name'); + } + + // Colonnes universelles PJSIP supplémentaires (Session Timers, Fax T.38, Diversion, Retries, Langue, Keepalive) + $extraColumns = [ + 'max_retries' => "INTEGER NOT NULL DEFAULT 10000", + 'forbidden_retry_interval' => "INTEGER NOT NULL DEFAULT 600", + 'fatal_retry_interval' => "INTEGER NOT NULL DEFAULT 0", + 'send_diversion' => "INTEGER NOT NULL DEFAULT 1", + 'timers' => "VARCHAR(16) NOT NULL DEFAULT 'yes'", + 'language' => "VARCHAR(16) NOT NULL DEFAULT 'fr'", + 'rtp_keepalive' => "INTEGER NOT NULL DEFAULT 15", + 't38_udptl' => "VARCHAR(16) NOT NULL DEFAULT 'no'", + 'fax_detect' => "INTEGER NOT NULL DEFAULT 0", + 'inband_progress' => "INTEGER NOT NULL DEFAULT 0", + 'custom_reg_params' => "TEXT NOT NULL DEFAULT ''", + 'custom_aor_params' => "TEXT NOT NULL DEFAULT ''", + 'moh_sound_id' => "INTEGER NOT NULL DEFAULT 0", + ]; + + foreach ($extraColumns as $colName => $colDef) { + if (!in_array($colName, $existing, true)) { + $db->exec("ALTER TABLE trunks ADD COLUMN {$colName} {$colDef}"); + } + } + } + + private static function migrateOutboundRoutesTable(PDO $db): void + { + $count = (int)$db->query('SELECT COUNT(*) FROM outbound_routes')->fetchColumn(); + if ($count === 0) { + // Vérifie quels trunks et postes existent déjà pour pré-initialiser la route sortante principale + $trunks = $db->query('SELECT name, outbound_callerid FROM trunks ORDER BY id ASC')->fetchAll(); + $firstTrunkName = !empty($trunks) ? (string)$trunks[0]['name'] : 'ovh-telecom'; + $firstTrunkCid = !empty($trunks) && trim((string)$trunks[0]['outbound_callerid']) !== '' + ? trim((string)$trunks[0]['outbound_callerid']) + : 'Sytelis Consulting <0422560185>'; + + $exts = $db->query('SELECT extension FROM extensions ORDER BY CAST(extension AS INTEGER) ASC')->fetchAll(PDO::FETCH_COLUMN); + $grps = $db->query('SELECT extension FROM groups ORDER BY CAST(extension AS INTEGER) ASC')->fetchAll(PDO::FETCH_COLUMN); + + $stmt = $db->prepare(' + INSERT INTO outbound_routes ( + priority, name, outbound_callerid, override_ext_cid, route_password, + route_type, moh_class, trunk_sequence_json, dial_patterns_json, + permission_mode, allowed_groups_json, allowed_extensions_json, + continue_next_route, congestion_dest, record_mode, enabled + ) VALUES ( + :priority, :name, :cid, :override_cid, :pwd, + :rtype, :moh, :trunks, :patterns, + :pmode, :agroups, :aexts, + :cont, :cdest, :rec, 1 + ) + '); + + // Route 1 : OVH (principale) + $stmt->execute([ + ':priority' => 1, + ':name' => 'OVH', + ':cid' => $firstTrunkCid, + ':override_cid' => 0, + ':pwd' => '', + ':rtype' => 'normal', + ':moh' => 'default', + ':trunks' => json_encode([$firstTrunkName]), + ':patterns' => json_encode([ + ['prepend' => '', 'prefix' => '', 'match_pattern' => '0[1-59]XXXXXXXX', 'callerid' => ''], + ['prepend' => '', 'prefix' => '', 'match_pattern' => '0[67]XXXXXXXX', 'callerid' => ''], + ['prepend' => '', 'prefix' => '', 'match_pattern' => '080[0-9]XXXXXX', 'callerid' => ''], + ]), + ':pmode' => 'all', + ':agroups' => json_encode($grps), + ':aexts' => json_encode($exts), + ':cont' => 1, + ':cdest' => 'terminate-busy', + ':rec' => 'dontcare', + ]); + + // Route 2 : Emergency (Numéros d'urgence) + $stmt->execute([ + ':priority' => 2, + ':name' => 'Emergency', + ':cid' => $firstTrunkCid, + ':override_cid' => 1, + ':pwd' => '', + ':rtype' => 'emergency', + ':moh' => 'default', + ':trunks' => json_encode([$firstTrunkName]), + ':patterns' => json_encode([ + ['prepend' => '', 'prefix' => '', 'match_pattern' => '15', 'callerid' => ''], + ['prepend' => '', 'prefix' => '', 'match_pattern' => '17', 'callerid' => ''], + ['prepend' => '', 'prefix' => '', 'match_pattern' => '18', 'callerid' => ''], + ['prepend' => '', 'prefix' => '', 'match_pattern' => '112', 'callerid' => ''], + ['prepend' => '', 'prefix' => '', 'match_pattern' => '114', 'callerid' => ''], + ]), + ':pmode' => 'all', + ':agroups' => json_encode($grps), + ':aexts' => json_encode($exts), + ':cont' => 1, + ':cdest' => 'terminate-congestion', + ':rec' => 'dontcare', + ]); + } + } + + private static function migrateExtensionGroupsTable(PDO $db): void + { + $count = (int)$db->query('SELECT COUNT(*) FROM extension_groups')->fetchColumn(); + if ($count === 0) { + $stmt = $db->prepare('INSERT INTO extension_groups (name, description, color, members) VALUES (:n, :d, :c, :m)'); + $stmt->execute([ + ':n' => 'Groupe Nice', + ':d' => 'Extensions du site de Nice (Trunk local Livebox)', + ':c' => 'emerald', + ':m' => json_encode(['101', '102']), + ]); + $stmt->execute([ + ':n' => 'Groupe Paris', + ':d' => 'Extensions du site de Paris (Trunk principal OVH)', + ':c' => 'indigo', + ':m' => json_encode(['103']), + ]); + } + } + + private static function migrateSoundsTable(PDO $db): void + { + $count = (int)$db->query('SELECT COUNT(*) FROM sounds')->fetchColumn(); + if ($count === 0) { + $stmt = $db->prepare(' + INSERT INTO sounds (name, slug, site_category, usage_type, description, filename, original_filename, duration_sec, file_size, format) + VALUES (:name, :slug, :site, :usage, :desc, :file, :orig, :dur, :size, :fmt) + '); + + $demoSounds = [ + [ + ':name' => 'Message d\'accueil SVI Standard', + ':slug' => 'bienvenue-entreprise', + ':site' => 'Global', + ':usage' => 'ivr', + ':desc' => 'Jingle d\'accueil principal du serveur vocal interactif', + ':file' => 'bienvenue-entreprise.wav', + ':orig' => 'bienvenue-entreprise.wav', + ':dur' => 2, + ':size' => 31800, + ':fmt' => 'wav', + ], + [ + ':name' => 'Musique d\'attente Standard', + ':slug' => 'attente-musicale-standard', + ':site' => 'Global', + ':usage' => 'moh', + ':desc' => 'Mélodie douce d\'attente (MoH) par défaut pour les appels mis en garde', + ':file' => 'attente-musicale-standard.wav', + ':orig' => 'attente-musicale-standard.wav', + ':dur' => 4, + ':size' => 63600, + ':fmt' => 'wav', + ], + [ + ':name' => 'Message Ligne Occupée & Congestion', + ':slug' => 'ligne-occupee-congestion', + ':site' => 'Global', + ':usage' => 'congestion', + ':desc' => 'Tonalité cadencée d\'occupation si aucun trunk ou poste n\'est disponible', + ':file' => 'ligne-occupee-congestion.wav', + ':orig' => 'ligne-occupee-congestion.wav', + ':dur' => 1, + ':size' => 16000, + ':fmt' => 'wav', + ], + [ + ':name' => 'Annonce Fermeture Nice', + ':slug' => 'annonce-fermeture-nice', + ':site' => 'Site Nice', + ':usage' => 'announcement', + ':desc' => 'Message informant des horaires d\'ouverture du bureau de Nice', + ':file' => 'annonce-fermeture-nice.wav', + ':orig' => 'annonce-fermeture-nice.wav', + ':dur' => 2, + ':size' => 28800, + ':fmt' => 'wav', + ], + ]; + + foreach ($demoSounds as $ds) { + $stmt->execute($ds); + } + } + } + + private static function migrateSoundSitesTable(PDO $db): void + { + $count = (int)$db->query('SELECT COUNT(*) FROM sound_sites')->fetchColumn(); + if ($count === 0) { + $stmt = $db->prepare('INSERT INTO sound_sites (name, description) VALUES (:n, :d)'); + $stmt->execute([':n' => 'Global', ':d' => 'Fichiers généraux partagés par tout le PBX']); + $stmt->execute([':n' => 'Site Nice', ':d' => 'Annonces et musiques spécifiques au site de Nice']); + $stmt->execute([':n' => 'Site Paris', ':d' => 'Annonces et musiques spécifiques au site de Paris']); + } + } + + private static function migrateIvrsTable(PDO $db): void + { + $cols = $db->query("PRAGMA table_info(ivrs)")->fetchAll(PDO::FETCH_ASSOC); + $existing = array_column($cols, 'name'); + + $extraColumns = [ + 'description' => "TEXT NOT NULL DEFAULT ''", + 'direct_dial' => "INTEGER NOT NULL DEFAULT 1", + 'invalid_retries' => "INTEGER NOT NULL DEFAULT 3", + 'invalid_sound_id' => "INTEGER NOT NULL DEFAULT 0", + 'invalid_dest' => "VARCHAR(64) NOT NULL DEFAULT 'hangup'", + 'timeout_retries' => "INTEGER NOT NULL DEFAULT 2", + 'timeout_sound_id' => "INTEGER NOT NULL DEFAULT 0", + 'timeout_dest' => "VARCHAR(64) NOT NULL DEFAULT 'hangup'", + ]; + + foreach ($extraColumns as $colName => $colDef) { + if (!in_array($colName, $existing, true)) { + $db->exec("ALTER TABLE ivrs ADD COLUMN {$colName} {$colDef}"); + } + } + } + + private static function migrateCertificatesTable(PDO $db): void + { + $count = (int)$db->query('SELECT COUNT(*) FROM certificates')->fetchColumn(); + if ($count === 0) { + $defaultCert = '/etc/asterisk/keys/asterisk.crt'; + $defaultKey = '/etc/asterisk/keys/asterisk.key'; + + $domain = 'pbx.local'; + $issuer = 'Asterisk PBX System'; + $validFrom = date('Y-m-d H:i:s'); + $validTo = date('Y-m-d H:i:s', time() + (3650 * 86400)); + $daysRemaining = 3650; + + if (file_exists($defaultCert)) { + $raw = @file_get_contents($defaultCert); + if ($raw) { + $parsed = @openssl_x509_parse($raw); + if ($parsed) { + $domain = $parsed['subject']['CN'] ?? 'pbx.local'; + $issuer = $parsed['issuer']['O'] ?? ($parsed['issuer']['CN'] ?? 'Asterisk Self-Signed CA'); + if (!empty($parsed['validFrom_time_t'])) { + $validFrom = date('Y-m-d H:i:s', $parsed['validFrom_time_t']); + } + if (!empty($parsed['validTo_time_t'])) { + $validTo = date('Y-m-d H:i:s', $parsed['validTo_time_t']); + $daysRemaining = (int)round(($parsed['validTo_time_t'] - time()) / 86400); + } + } + } + } + + $stmt = $db->prepare('INSERT INTO certificates ( + name, type, primary_domain, alt_names, issuer, valid_from, valid_to, days_remaining, + cert_path, key_path, chain_path, fullchain_path, is_default, status + ) VALUES ( + :name, :type, :domain, :alt_names, :issuer, :valid_from, :valid_to, :days_rem, + :cert_path, :key_path, :chain_path, :fullchain_path, 1, :status + )'); + + $stmt->execute([ + ':name' => 'Certificat Auto-signé Système', + ':type' => 'selfsigned', + ':domain' => $domain, + ':alt_names' => '', + ':issuer' => $issuer, + ':valid_from' => $validFrom, + ':valid_to' => $validTo, + ':days_rem' => $daysRemaining, + ':cert_path' => $defaultCert, + ':key_path' => $defaultKey, + ':chain_path' => '', + ':fullchain_path' => $defaultCert, + ':status' => ($daysRemaining > 0) ? 'valid' : 'expired', + ]); + } + } + + private static function migrateFeatureCodesTable(PDO $db): void + { + $db->exec("CREATE TABLE IF NOT EXISTS feature_codes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + feature_key VARCHAR(64) UNIQUE NOT NULL, + code VARCHAR(16) NOT NULL, + default_code VARCHAR(16) NOT NULL, + name VARCHAR(128) NOT NULL, + category VARCHAR(64) NOT NULL, + description TEXT NOT NULL DEFAULT '', + enabled INTEGER NOT NULL DEFAULT 1, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + )"); + + $defaults = [ + // Supervision & Interception BLF + ['pickup_blf', '**', '**', "Interception d'appel dirigée (BLF Yealink)", 'pickup', "Récupère l'appel du poste surveillé en appuyant sur sa touche BLF ou en composant ** suivi de son numéro."], + ['pickup_group', '*8', '*8', "Interception d'appel de groupe", 'pickup', "Récupère n'importe quel appel qui sonne dans votre groupe de postes (ou composez *8 suivi du numéro pour une capture dirigée)."], + + // Renvois d'appels + ['cf_activate', '*72', '*72', "Activer le renvoi d'appels inconditionnel", 'forward', "Renvoie immédiatement tous les appels vers un autre numéro (composez *72 ou *72 suivi du numéro de destination)."], + ['cf_deactivate', '*73', '*73', "Désactiver le renvoi d'appels inconditionnel", 'forward', "Annule le renvoi inconditionnel de vos appels."], + ['cfu_activate', '*52', '*52', "Activer le renvoi sur non-réponse / indisponibilité", 'forward', "Renvoie vos appels uniquement en cas de non-réponse ou d'indisponibilité."], + ['cfu_deactivate', '*53', '*53', "Désactiver le renvoi sur non-réponse / indisponibilité", 'forward', "Annule le renvoi sur non-réponse / indisponibilité."], + + // Ne pas déranger (DND) + ['dnd_activate', '*78', '*78', "Activer le mode Ne-pas-Déranger (DND)", 'dnd', "Rejette les appels entrants en envoyant le signal occupé ou renvoie vers la messagerie."], + ['dnd_deactivate', '*79', '*79', "Désactiver le mode Ne-pas-Déranger (DND)", 'dnd', "Rétablit la sonnerie normale de votre combiné téléphonique."], + ['dnd_toggle', '*76', '*76', "Inverser l'état Ne-pas-Déranger (Toggle)", 'dnd', "Bascule rapidement entre l'état Ne-pas-déranger Actif et Inactif."], + + // Messagerie vocale + ['voicemail_direct', '*97', '*97', "Accès direct à votre boîte vocale", 'voicemail', "Consulte vos messages vocaux sans mot de passe depuis votre propre poste."], + ['voicemail_general', '*98', '*98', "Consulter la boîte vocale d'une autre extension", 'voicemail', "Accède à n'importe quelle boîte vocale en saisissant le numéro de poste et le code PIN."], + + // Sécurité & Filtrage + ['blacklist_add', '*30', '*30', "Ajouter un numéro à la liste noire (Blacklist)", 'security', "Bloque définitivement les appels provenant d'un numéro indésirable."], + ['blacklist_remove', '*31', '*31', "Retirer un numéro de la liste noire", 'security', "Débloque un numéro précédemment blacklisté."], + ['blacklist_last', '*32', '*32', "Bloquer instantanément le dernier appelant", 'security', "Ajoute immédiatement le numéro du dernier appel reçu à la liste noire."], + + // Outils & Diagnostics + ['speak_exten', '*65', '*65', "Identifier vocalement votre numéro de poste", 'tools', "Asterisk énonce à voix haute le numéro de l'extension actuelle (Speak your extension)."], + ['last_caller', '*69', '*69', "Identifier vocalement le dernier appelant reçu", 'tools', "Énonce le numéro du dernier appel reçu et propose de le rappeler."], + ['echo_test', '*43', '*43', "Test d'écho audio bidirectionnel", 'tools', "Permet de tester micro, écouteur et latence réseau en renvoyant votre voix en miroir."], + ['speaking_clock', '*60', '*60', "Horloge parlante / Test audio", 'tools', "Donne la date et l'heure exacte et valide le fonctionnement des codecs audio."], + ['moh_test', '*44', '*44', "Test d'écoute de la musique d'attente (MoH)", 'tools', "Diffuse la musique d'attente générale pour valider la qualité sonore."] + ]; + + $stmtCheck = $db->prepare('SELECT COUNT(*) FROM feature_codes WHERE feature_key = :k'); + $stmtInsert = $db->prepare('INSERT INTO feature_codes (feature_key, code, default_code, name, category, description, enabled) VALUES (:k, :c, :def, :n, :cat, :d, 1)'); + + foreach ($defaults as $item) { + $stmtCheck->execute([':k' => $item[0]]); + if ((int)$stmtCheck->fetchColumn() === 0) { + $stmtInsert->execute([ + ':k' => $item[0], + ':c' => $item[1], + ':def' => $item[2], + ':n' => $item[3], + ':cat' => $item[4], + ':d' => $item[5], + ]); + } + } + } +} + diff --git a/src/FirewallManager.php b/src/FirewallManager.php new file mode 100644 index 0000000..d0035a2 --- /dev/null +++ b/src/FirewallManager.php @@ -0,0 +1,553 @@ +query("SELECT + firewall_enabled, admin_https_port, certbot_strict_port80, + fail2ban_sip_maxretry, fail2ban_sip_findtime, fail2ban_sip_bantime, + fail2ban_web_maxretry, fail2ban_web_bantime, fail2ban_whitelist_ips + FROM settings WHERE id = 1"); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + + if (!$row) { + return [ + 'firewall_enabled' => 1, + 'admin_https_port' => 8443, + 'certbot_strict_port80' => 0, + 'fail2ban_sip_maxretry' => 3, + 'fail2ban_sip_findtime' => 600, + 'fail2ban_sip_bantime' => 3600, + 'fail2ban_web_maxretry' => 3, + 'fail2ban_web_bantime' => 3600, + 'fail2ban_whitelist_ips' => '127.0.0.1/8 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 82.122.84.60', + ]; + } + + return [ + 'firewall_enabled' => (int)($row['firewall_enabled'] ?? 1), + 'admin_https_port' => (int)($row['admin_https_port'] ?? 8443), + 'certbot_strict_port80' => (int)($row['certbot_strict_port80'] ?? 0), + 'fail2ban_sip_maxretry' => (int)($row['fail2ban_sip_maxretry'] ?? 3), + 'fail2ban_sip_findtime' => (int)($row['fail2ban_sip_findtime'] ?? 600), + 'fail2ban_sip_bantime' => (int)($row['fail2ban_sip_bantime'] ?? 3600), + 'fail2ban_web_maxretry' => (int)($row['fail2ban_web_maxretry'] ?? 3), + 'fail2ban_web_bantime' => (int)($row['fail2ban_web_bantime'] ?? 3600), + 'fail2ban_whitelist_ips' => (string)($row['fail2ban_whitelist_ips'] ?? '127.0.0.1/8 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 82.122.84.60'), + ]; + } + + /** + * Retourne l'état complet en direct de Fail2ban et les IPs bannies. + * Utilise un cache rapide de 4 secondes pour soulager le tableau de bord temps réel. + */ + public static function getStatus(bool $forceFresh = false): array + { + $cacheFile = '/tmp/nexus_fw_status_cache.json'; + if (!$forceFresh && file_exists($cacheFile)) { + $mtime = @filemtime($cacheFile); + if ($mtime && (time() - $mtime) < 4) { + $cached = json_decode((string)file_get_contents($cacheFile), true); + if (is_array($cached)) { + return $cached; + } + } + } + + $settings = self::getSettings(); + + // 1. Détection service Fail2ban + exec('sudo /usr/bin/fail2ban-client ping 2>&1', $pingOut, $pingCode); + $isRunning = ($pingCode === 0 && str_contains(implode(' ', $pingOut), 'Server replied: pong')); + + $jails = []; + $bannedList = []; + $totalBannedCount = 0; + + if ($isRunning) { + exec('sudo /usr/bin/fail2ban-client status 2>&1', $statOut); + $statStr = implode("\n", $statOut); + + // Extraction des prisons actives + if (preg_match('/Jail list:\s+(.+)$/m', $statStr, $m)) { + $jailNames = array_map('trim', explode(',', $m[1])); + foreach ($jailNames as $jName) { + if ($jName === '') continue; + $jStatus = self::getJailDetails($jName); + $jails[$jName] = $jStatus; + + foreach ($jStatus['banned_ips'] as $bIp) { + $bannedList[] = [ + 'ip' => $bIp, + 'jail' => $jName, + 'jail_label' => self::formatJailName($jName), + ]; + $totalBannedCount++; + } + } + } + } + + // IP actuelle du client + $currentIp = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1'; + if (str_contains($currentIp, ',')) { + $currentIp = trim(explode(',', $currentIp)[0]); + } + + $status = [ + 'is_running' => $isRunning, + 'settings' => $settings, + 'jails' => $jails, + 'banned_ips' => $bannedList, + 'total_banned' => $totalBannedCount, + 'client_ip' => $currentIp, + ]; + + @file_put_contents($cacheFile, json_encode($status)); + return $status; + } + + /** + * Débannit une adresse IP d'une prison ou de toutes les prisons. + */ + public static function unbanIp(string $ip, string $jail = ''): array + { + $ip = trim($ip); + if ($ip === '' || (!filter_var($ip, FILTER_VALIDATE_IP) && !filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6))) { + throw new InvalidArgumentException("Adresse IP invalide : {$ip}"); + } + + $jail = trim($jail); + $unbannedFrom = []; + + if ($jail !== '' && $jail !== 'all') { + $cmd = "sudo /usr/bin/fail2ban-client set " . escapeshellarg($jail) . " unbanip " . escapeshellarg($ip) . " 2>&1"; + exec($cmd, $out, $code); + if ($code === 0) { + $unbannedFrom[] = $jail; + } + } else { + // Débannir de toutes les prisons connues + foreach (['asterisk', 'nexus-web', 'sshd'] as $j) { + $cmd = "sudo /usr/bin/fail2ban-client set " . escapeshellarg($j) . " unbanip " . escapeshellarg($ip) . " 2>&1"; + exec($cmd, $out, $code); + if ($code === 0) { + $unbannedFrom[] = $j; + } + } + } + + @unlink('/tmp/nexus_fw_status_cache.json'); + + return [ + 'success' => count($unbannedFrom) > 0, + 'message' => count($unbannedFrom) > 0 + ? "L'adresse IP {$ip} a été débannie avec succès (" . implode(', ', $unbannedFrom) . ")." + : "L'adresse IP {$ip} n'a pas été trouvée dans les listes de bannissement actives.", + ]; + } + + /** + * Bannit manuellement une adresse IP. + */ + public static function banIp(string $ip, string $jail = 'asterisk'): array + { + $ip = trim($ip); + if ($ip === '' || (!filter_var($ip, FILTER_VALIDATE_IP) && !filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6))) { + throw new InvalidArgumentException("Adresse IP invalide : {$ip}"); + } + + $jail = trim($jail) ?: 'asterisk'; + $cmd = "sudo /usr/bin/fail2ban-client set " . escapeshellarg($jail) . " banip " . escapeshellarg($ip) . " 2>&1"; + exec($cmd, $out, $code); + @unlink('/tmp/nexus_fw_status_cache.json'); + + return [ + 'success' => $code === 0, + 'message' => $code === 0 + ? "L'adresse IP {$ip} a été bannie sur la règle {$jail}." + : "Échec du bannissement : " . implode("\n", $out), + ]; + } + + /** + * Enregistre les paramètres du pare-feu, régénère jail.local et la configuration Nginx. + */ + public static function saveSettings(array $data): array + { + $db = Database::getConnection(); + + $strictPort80 = !empty($data['certbot_strict_port80']) ? 1 : 0; + $firewallEnabled = (!empty($data['firewall_enabled']) || $strictPort80 === 1) ? 1 : 0; + $adminPort = max(1, min(65535, (int)($data['admin_https_port'] ?? 8443))); + $sipMaxRetry = max(1, min(20, (int)($data['fail2ban_sip_maxretry'] ?? 3))); + $sipFindTime = max(60, min(86400, (int)($data['fail2ban_sip_findtime'] ?? 600))); + $sipBanTime = max(60, min(31536000, (int)($data['fail2ban_sip_bantime'] ?? 3600))); + $webMaxRetry = max(1, min(20, (int)($data['fail2ban_web_maxretry'] ?? 3))); + $webBanTime = max(60, min(31536000, (int)($data['fail2ban_web_bantime'] ?? 3600))); + + $whitelist = trim((string)($data['fail2ban_whitelist_ips'] ?? '')); + $cleanTokens = array_values(array_filter(preg_split('/\s+/', $whitelist))); + + // Toujours préserver l'adresse de bouclage locale pour les processus internes + if (!in_array('127.0.0.1/8', $cleanTokens, true)) { + array_unshift($cleanTokens, '127.0.0.1/8'); + } + if (!in_array('::1', $cleanTokens, true)) { + $cleanTokens[] = '::1'; + } + + $finalWhitelist = implode(' ', array_unique($cleanTokens)); + + // 1. Sauvegarde dans la base SQLite + $stmt = $db->prepare("UPDATE settings SET + firewall_enabled = :fe, + admin_https_port = :ap, + certbot_strict_port80 = :sp80, + fail2ban_sip_maxretry = :smr, + fail2ban_sip_findtime = :sft, + fail2ban_sip_bantime = :sbt, + fail2ban_web_maxretry = :wmr, + fail2ban_web_bantime = :wbt, + fail2ban_whitelist_ips = :wl, + updated_at = CURRENT_TIMESTAMP + WHERE id = 1"); + + $stmt->execute([ + ':fe' => $firewallEnabled, + ':ap' => $adminPort, + ':sp80' => $strictPort80, + ':smr' => $sipMaxRetry, + ':sft' => $sipFindTime, + ':sbt' => $sipBanTime, + ':wmr' => $webMaxRetry, + ':wbt' => $webBanTime, + ':wl' => $finalWhitelist, + ]); + + // 2. Génération et application du fichier /etc/fail2ban/jail.local + self::generateJailLocal( + $finalWhitelist, + $sipMaxRetry, + $sipFindTime, + $sipBanTime, + $webMaxRetry, + $webBanTime, + $adminPort + ); + + // 3. Rechargement de Fail2ban + exec('sudo /usr/bin/fail2ban-client reload 2>&1', $f2bOut, $f2bCode); + + // 4. Mise à jour de la configuration Nginx + self::applyNginxConfig($adminPort, $strictPort80); + + @unlink('/tmp/nexus_fw_status_cache.json'); + + return [ + 'success' => true, + 'message' => 'Configuration Pare-feu & Fail2ban appliquée avec succès ! Nginx et Fail2ban rechargés.', + 'settings' => self::getSettings(), + ]; + } + + /** + * Écrit le fichier /etc/fail2ban/jail.local + */ + public static function generateJailLocal( + string $whitelist, + int $sipMaxRetry, + int $sipFindTime, + int $sipBanTime, + int $webMaxRetry, + int $webBanTime, + int $adminPort + ): void { + $portsWeb = "http,https,{$adminPort}"; + + $conf = << 0) { + $listenCustom = "\n listen {$adminHttpsPort} ssl;\n listen [::]:{$adminHttpsPort} ssl;"; + } + + $httpsBlock = <<&1', $tOut, $tCode); + if ($tCode !== 0) { + throw new RuntimeException("Erreur de syntaxe Nginx : " . implode("\n", $tOut)); + } + + // Rechargement Nginx sans coupure + exec('sudo /usr/bin/systemctl reload nginx 2>&1'); + } + + private static function getJailDetails(string $jail): array + { + exec('sudo /usr/bin/fail2ban-client status ' . escapeshellarg($jail) . ' 2>&1', $out); + $str = implode("\n", $out); + + $currentlyFailed = 0; + $totalFailed = 0; + $currentlyBanned = 0; + $totalBanned = 0; + $bannedIps = []; + + if (preg_match('/Currently failed:\s+(\d+)/', $str, $m)) { + $currentlyFailed = (int)$m[1]; + } + if (preg_match('/Total failed:\s+(\d+)/', $str, $m)) { + $totalFailed = (int)$m[1]; + } + + // Intégrer également les événements consignés dans le journal physique + if ($jail === 'asterisk') { + $logIncidents = is_readable('/var/log/asterisk/security.log') + ? (int)shell_exec("grep -c -E 'ChallengeResponseFailed|InvalidAccountID|FailedAuth' /var/log/asterisk/security.log 2>/dev/null") + : 0; + $totalFailed = max($totalFailed, $logIncidents); + } elseif ($jail === 'nexus-web') { + $logIncidents = is_readable('/var/log/nexus_security.log') + ? (int)shell_exec("grep -c 'FailedLogin' /var/log/nexus_security.log 2>/dev/null") + : 0; + $totalFailed = max($totalFailed, $logIncidents); + } + if (preg_match('/Currently banned:\s+(\d+)/', $str, $m)) { + $currentlyBanned = (int)$m[1]; + } + if (preg_match('/Total banned:\s+(\d+)/', $str, $m)) { + $totalBanned = (int)$m[1]; + } + if (preg_match('/Banned IP list:\s*(.*)$/m', $str, $m)) { + $rawIps = trim($m[1]); + if ($rawIps !== '') { + $bannedIps = array_values(array_filter(preg_split('/\s+/', $rawIps))); + } + } + + return [ + 'name' => $jail, + 'currently_failed' => $currentlyFailed, + 'total_failed' => $totalFailed, + 'currently_banned' => $currentlyBanned, + 'total_banned' => $totalBanned, + 'banned_ips' => $bannedIps, + ]; + } + + private static function formatJailName(string $jail): string + { + return match ($jail) { + 'asterisk' => 'Téléphonie SIP (PJSIP)', + 'nexus-web' => 'Interface Web Admin', + 'sshd' => 'Accès Console SSH', + default => ucfirst($jail), + }; + } +} diff --git a/src/PhonebookManager.php b/src/PhonebookManager.php new file mode 100644 index 0000000..6f466e3 --- /dev/null +++ b/src/PhonebookManager.php @@ -0,0 +1,303 @@ +query("SELECT id, name, members FROM extension_groups")->fetchAll(); + $extToGroup = []; + foreach ($egRows as $eg) { + $members = json_decode((string)$eg['members'], true) ?: []; + foreach ($members as $extNum) { + if (!isset($extToGroup[(string)$extNum])) { + $extToGroup[(string)$extNum] = trim((string)$eg['name']); + } + } + } + + // 2. Récupère tous les postes + $exts = $db->query("SELECT id, extension, name, voicemail_email FROM extensions")->fetchAll(); + $activeExtIds = []; + + $findStmt = $db->prepare("SELECT id, display_name, internal_number, email, group_name FROM phonebook_contacts WHERE extension_id = :ext_id OR (extension_id IS NULL AND is_internal = 1 AND internal_number = :num)"); + $insertStmt = $db->prepare(" + INSERT INTO phonebook_contacts (extension_id, display_name, internal_number, email, group_name, is_internal) + VALUES (:ext_id, :name, :num, :email, :grp, 1) + "); + $updateStmt = $db->prepare(" + UPDATE phonebook_contacts + SET extension_id = :ext_id, display_name = :name, internal_number = :num, email = :email, group_name = :grp, updated_at = CURRENT_TIMESTAMP + WHERE id = :id + "); + + foreach ($exts as $ext) { + $extId = (int)$ext['id']; + $extNum = trim((string)$ext['extension']); + $extName = trim((string)$ext['name']); + $email = trim((string)($ext['voicemail_email'] ?? '')); + $group = $extToGroup[$extNum] ?? 'Général'; + $activeExtIds[] = $extId; + + $findStmt->execute([':ext_id' => $extId, ':num' => $extNum]); + $existing = $findStmt->fetch(PDO::FETCH_ASSOC); + + if ($existing) { + $updateStmt->execute([ + ':id' => (int)$existing['id'], + ':ext_id' => $extId, + ':name' => $extName, + ':num' => $extNum, + ':email' => $email, + ':grp' => $group, + ]); + } else { + $insertStmt->execute([ + ':ext_id' => $extId, + ':name' => $extName, + ':num' => $extNum, + ':email' => $email, + ':grp' => $group, + ]); + } + } + + // 3. Supprime les contacts internes orphelins (si extension supprimée) + if (!empty($activeExtIds)) { + $inClause = implode(',', $activeExtIds); + $db->exec("DELETE FROM phonebook_contacts WHERE is_internal = 1 AND extension_id IS NOT NULL AND extension_id NOT IN ({$inClause})"); + } + } + + /** + * Récupère la liste des contacts filtrée ou non par groupe. + */ + public static function getContacts(PDO $db, ?string $group = null): array + { + self::syncExtensions($db); + + $params = []; + $sql = "SELECT * FROM phonebook_contacts"; + + if ($group !== null && trim($group) !== '' && strtolower(trim($group)) !== 'all' && strtolower(trim($group)) !== 'tous') { + $cleanGroup = trim($group); + $sql .= " WHERE LOWER(group_name) = LOWER(:grp) OR LOWER(group_name) LIKE LOWER(:grp_like) OR LOWER(group_name) IN ('tous', 'all')"; + $params[':grp'] = $cleanGroup; + $params[':grp_like'] = "%{$cleanGroup}%"; + } + + $sql .= " ORDER BY display_name COLLATE NOCASE ASC"; + $stmt = $db->prepare($sql); + $stmt->execute($params); + return $stmt->fetchAll(PDO::FETCH_ASSOC); + } + + /** + * Récupère la liste de tous les groupes d'annuaire disponibles. + */ + public static function getGroups(PDO $db): array + { + $groups = []; + $egRows = $db->query("SELECT name FROM extension_groups ORDER BY name ASC")->fetchAll(PDO::FETCH_COLUMN); + foreach ($egRows as $g) { + $g = trim((string)$g); + if ($g !== '') $groups[$g] = true; + } + $pbRows = $db->query("SELECT DISTINCT group_name FROM phonebook_contacts WHERE group_name != ''")->fetchAll(PDO::FETCH_COLUMN); + foreach ($pbRows as $g) { + $g = trim((string)$g); + if ($g !== '') $groups[$g] = true; + } + + // Ajoute Issy-Les-Moulineaux si pas encore présent + if (!isset($groups['Issy-Les-Moulineaux'])) { + $groups['Issy-Les-Moulineaux'] = true; + } + + $result = array_keys($groups); + sort($result, SORT_NATURAL | SORT_FLAG_CASE); + return $result; + } + + /** + * Enregistre un contact (création ou mise à jour). + */ + public static function saveContact(PDO $db, array $data): int + { + $id = isset($data['id']) && $data['id'] !== '' ? (int)$data['id'] : null; + $name = trim((string)($data['display_name'] ?? '')); + $internal = trim((string)($data['internal_number'] ?? '')); + $mobile = trim((string)($data['mobile_number'] ?? '')); + $other = trim((string)($data['other_number'] ?? '')); + $email = trim((string)($data['email'] ?? '')); + $photo = trim((string)($data['photo_url'] ?? '')); + $group = trim((string)($data['group_name'] ?? 'Général')) ?: 'Général'; + $isInternal = !empty($data['is_internal']) ? 1 : 0; + $extensionId = !empty($data['extension_id']) ? (int)$data['extension_id'] : null; + + if ($name === '') { + throw new InvalidArgumentException("Le nom du contact est obligatoire."); + } + if ($internal === '' && $mobile === '' && $other === '') { + throw new InvalidArgumentException("Veuillez renseigner au moins un numéro de téléphone (Poste, Mobile ou Autre)."); + } + + if ($id) { + $stmt = $db->prepare(" + UPDATE phonebook_contacts + SET display_name = :name, internal_number = :internal, mobile_number = :mobile, + other_number = :other, email = :email, photo_url = :photo, group_name = :grp, + is_internal = :is_int, extension_id = :ext_id, updated_at = CURRENT_TIMESTAMP + WHERE id = :id + "); + $stmt->execute([ + ':id' => $id, + ':name' => $name, + ':internal' => $internal, + ':mobile' => $mobile, + ':other' => $other, + ':email' => $email, + ':photo' => $photo, + ':grp' => $group, + ':is_int' => $isInternal, + ':ext_id' => $extensionId, + ]); + return $id; + } else { + $stmt = $db->prepare(" + INSERT INTO phonebook_contacts (display_name, internal_number, mobile_number, other_number, email, photo_url, group_name, is_internal, extension_id) + VALUES (:name, :internal, :mobile, :other, :email, :photo, :grp, :is_int, :ext_id) + "); + $stmt->execute([ + ':name' => $name, + ':internal' => $internal, + ':mobile' => $mobile, + ':other' => $other, + ':email' => $email, + ':photo' => $photo, + ':grp' => $group, + ':is_int' => $isInternal, + ':ext_id' => $extensionId, + ]); + return (int)$db->lastInsertId(); + } + } + + /** + * Supprime un contact d'annuaire. + */ + public static function deleteContact(PDO $db, int $id): bool + { + $stmt = $db->prepare("DELETE FROM phonebook_contacts WHERE id = :id"); + return $stmt->execute([':id' => $id]); + } + + /** + * Détermine l'URL de base pour l'accès aux photos et avatars depuis les postes IP. + */ + public static function getBaseUrl(?PDO $db = null): string + { + if (!empty($_SERVER['HTTP_HOST'])) { + $scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http'; + return "{$scheme}://" . $_SERVER['HTTP_HOST']; + } + + if ($db) { + try { + $st = $db->query("SELECT domain, external_ip FROM settings WHERE id = 1")->fetch(PDO::FETCH_ASSOC); + if (!empty($st['external_ip'])) { + return "http://" . $st['external_ip'] . ":8080"; + } + if (!empty($st['domain']) && $st['domain'] !== 'pbx.local') { + return "http://" . $st['domain'] . ":8080"; + } + } catch (\Throwable $e) {} + } + + $serverIp = !empty($_SERVER['SERVER_ADDR']) ? $_SERVER['SERVER_ADDR'] : '127.0.0.1'; + return "http://{$serverIp}:8080"; + } + + /** + * Génère le flux XML officiel Yealink IP Phone Remote Address Book. + */ + public static function renderYealinkXml(PDO $db, ?string $group = null): string + { + $contacts = self::getContacts($db, $group); + $baseUrl = self::getBaseUrl($db); + $groupTitle = ($group !== null && trim($group) !== '' && strtolower(trim($group)) !== 'all') + ? trim($group) + : 'NexusPBX'; + + $xml = new \SimpleXMLElement(''); + $xml->addChild('Title', "Annuaire " . htmlspecialchars($groupTitle, ENT_XML1, 'UTF-8')); + + foreach ($contacts as $c) { + $name = trim((string)$c['display_name']); + $phone1 = trim((string)$c['internal_number']); + $phone2 = trim((string)$c['mobile_number']); + $phone3 = trim((string)$c['other_number']); + $email = trim((string)($c['email'] ?? '')); + $photo = trim((string)($c['photo_url'] ?? '')); + + if ($phone1 === '' && $phone2 !== '') { + $phone1 = $phone2; + $phone2 = ''; + } + + if ($phone1 === '' && $phone2 === '' && $phone3 === '') { + continue; + } + + $entry = $xml->addChild('DirectoryEntry'); + $entry->addChild('Name', htmlspecialchars($name, ENT_XML1, 'UTF-8')); + $entry->addChild('Telephone', htmlspecialchars($phone1, ENT_XML1, 'UTF-8')); + if ($phone2 !== '') { + $entry->addChild('Mobile', htmlspecialchars($phone2, ENT_XML1, 'UTF-8')); + } + if ($phone3 !== '') { + $entry->addChild('OtherTelephone', htmlspecialchars($phone3, ENT_XML1, 'UTF-8')); + } + if ($email !== '') { + $entry->addChild('Email', htmlspecialchars($email, ENT_XML1, 'UTF-8')); + } + if ($photo !== '') { + $fullPhotoUrl = (strpos($photo, '://') === false) + ? rtrim($baseUrl, '/') . '/' . ltrim($photo, '/') + : $photo; + $entry->addChild('Photo', htmlspecialchars($fullPhotoUrl, ENT_XML1, 'UTF-8')); + $entry->addChild('Picture', htmlspecialchars($fullPhotoUrl, ENT_XML1, 'UTF-8')); + } + } + + $dom = dom_import_simplexml($xml)->ownerDocument; + $dom->formatOutput = true; + return $dom->saveXML(); + } +} + diff --git a/src/SystemChecker.php b/src/SystemChecker.php new file mode 100644 index 0000000..2835e3f --- /dev/null +++ b/src/SystemChecker.php @@ -0,0 +1,209 @@ +query('SELECT * FROM settings WHERE id = 1')->fetch() ?: []; + $leDomain = trim((string)($settings['letsencrypt_domain'] ?? '')); + + $checks = []; + + // 1. OS Debian & PHP SQLite3 + $osPretty = 'Linux Debian'; + if (is_readable('/etc/os-release')) { + $osContent = (string)file_get_contents('/etc/os-release'); + if (preg_match('/PRETTY_NAME="([^"]+)"/', $osContent, $m)) { + $osPretty = $m[1]; + } + } + $checks[] = [ + 'id' => 'debian_os', + 'label' => 'Système Debian & PHP ' . PHP_VERSION, + 'ok' => extension_loaded('pdo_sqlite'), + 'detail' => $osPretty . ' (PDO SQLite actif)', + ]; + + // 2. Base SQLite accessible en écriture + $dbWritable = is_writable(Database::DB_PATH) && is_writable(dirname(Database::DB_PATH)); + $checks[] = [ + 'id' => 'sqlite_db', + 'label' => 'Base de données SQLite3 (Source unique)', + 'ok' => $dbWritable, + 'detail' => $dbWritable + ? 'Lecture/Écriture OK (' . realpath(Database::DB_PATH) . ')' + : 'Permissions insuffisantes sur storage/database.sqlite', + ]; + + // 3. Utilisateur et Groupe Debian asterisk:asterisk + $astUser = function_exists('posix_getpwnam') ? posix_getpwnam('asterisk') : true; + $astGroup = function_exists('posix_getgrnam') ? posix_getgrnam('asterisk') : true; + $checks[] = [ + 'id' => 'asterisk_user', + 'label' => 'Utilisateur & Groupe Debian (asterisk:asterisk)', + 'ok' => (bool)($astUser && $astGroup), + 'detail' => ($astUser && $astGroup) + ? 'Utilisateur système asterisk:asterisk présent' + : 'Utilisateur ou groupe asterisk introuvable sur Debian', + ]; + + // 4. Droits d'écriture sur /etc/asterisk et *.custom.conf + $etcWritable = is_dir('/etc/asterisk') && is_writable('/etc/asterisk'); + $pjsipOk = !file_exists(ConfigGenerator::PJSIP_CONF_PATH) ? $etcWritable : is_writable(ConfigGenerator::PJSIP_CONF_PATH); + $extOk = !file_exists(ConfigGenerator::EXTENSIONS_CONF_PATH) ? $etcWritable : is_writable(ConfigGenerator::EXTENSIONS_CONF_PATH); + $permOk = $etcWritable && $pjsipOk && $extOk; + $checks[] = [ + 'id' => 'asterisk_perms', + 'label' => 'Droits d\'écriture sur /etc/asterisk/*.custom.conf', + 'ok' => $permOk, + 'detail' => $permOk + ? '/etc/asterisk accessible en écriture par le worker PHP (groupe asterisk)' + : 'Accès refusé en écriture dans /etc/asterisk pour PHP-FPM', + ]; + + // 5. Directives #tryinclude dans pjsip.conf et extensions.conf + $pjsipMain = @file_get_contents('/etc/asterisk/pjsip.conf') ?: ''; + $extMain = @file_get_contents('/etc/asterisk/extensions.conf') ?: ''; + $incOk = str_contains($pjsipMain, 'pjsip.custom.conf') && str_contains($extMain, 'extensions.custom.conf'); + $checks[] = [ + 'id' => 'asterisk_includes', + 'label' => 'Inclusions natives (#tryinclude *.custom.conf)', + 'ok' => $incOk, + 'detail' => $incOk + ? 'pjsip.conf et extensions.conf incluent bien les fichiers .custom.conf' + : 'Directive #tryinclude manquante dans pjsip.conf ou extensions.conf', + ]; + + // 6. Certificats TLS (Let's Encrypt ou Certificat local Debian) + if ($leDomain !== '') { + $leCert = "/etc/letsencrypt/live/{$leDomain}/fullchain.pem"; + $leKey = "/etc/letsencrypt/live/{$leDomain}/privkey.pem"; + $leOk = is_readable($leCert) && is_readable($leKey); + $checks[] = [ + 'id' => 'tls_certs', + 'label' => "Certificat TLS Let's Encrypt ({$leDomain})", + 'ok' => $leOk, + 'detail' => $leOk + ? "Certificat Let's Encrypt lisible ({$leCert})" + : "Certificat introuvable ou non lisible dans /etc/letsencrypt/live/{$leDomain}/", + ]; + } else { + $fallbackOk = is_readable('/etc/asterisk/keys/asterisk.crt') && is_readable('/etc/asterisk/keys/asterisk.key'); + $checks[] = [ + 'id' => 'tls_certs', + 'label' => 'Certificats TLS SIP (Port 5061)', + 'ok' => $fallbackOk, + 'detail' => $fallbackOk + ? 'Certificat TLS local actif (/etc/asterisk/keys/asterisk.crt) — prêt pour Let\'s Encrypt' + : 'Aucun domaine Let\'s Encrypt configuré', + ]; + } + + // 7. Connexion Asterisk Manager Interface (AMI) + $amiOk = false; + $amiDetail = ''; + try { + $ami = AmiClient::fromDatabase($db); + $info = $ami->testConnection(); + $amiOk = true; + $amiDetail = ($info['version'] ?: $info['banner']) . ' (Socket AMI 127.0.0.1:' . ($settings['ami_port'] ?? 5038) . ')'; + } catch (Throwable $e) { + $amiDetail = $e->getMessage(); + } + + $checks[] = [ + 'id' => 'ami_connection', + 'label' => 'Liaison Asterisk Manager Interface (AMI)', + 'ok' => $amiOk, + 'detail' => $amiDetail, + ]; + + $allCriticalOk = true; + foreach ($checks as $c) { + if (!$c['ok'] && $c['id'] !== 'tls_certs') { + $allCriticalOk = false; + } + } + + return [ + 'healthy' => $allCriticalOk, + 'checks' => $checks, + ]; + } + + public static function ensureAsteriskIncludes(): void + { + $pjsipConf = '/etc/asterisk/pjsip.conf'; + if (is_file($pjsipConf) && is_writable($pjsipConf)) { + $content = (string)file_get_contents($pjsipConf); + if (!str_contains($content, 'pjsip.custom.conf')) { + file_put_contents($pjsipConf, rtrim($content) . "\n\n#tryinclude \"pjsip.custom.conf\"\n"); + } + } + + $extConf = '/etc/asterisk/extensions.conf'; + if (is_file($extConf) && is_writable($extConf)) { + $content = (string)file_get_contents($extConf); + if (!str_contains($content, 'extensions.custom.conf')) { + file_put_contents($extConf, rtrim($content) . "\n\n#tryinclude \"extensions.custom.conf\"\n"); + } + } + + $mohConf = '/etc/asterisk/musiconhold.conf'; + if (is_file($mohConf) && is_writable($mohConf)) { + $content = (string)file_get_contents($mohConf); + // Désactive la classe [default] native d'Asterisk pour laisser la priorité à musiconhold.custom.conf + $cleaned = preg_replace('/^\[default\]/m', ';[default]', $content); + $cleaned = preg_replace('/^mode=files/m', ';mode=files', $cleaned); + $cleaned = preg_replace('/^directory=moh/m', ';directory=moh', $cleaned); + if (!str_contains($cleaned, 'musiconhold.custom.conf')) { + $cleaned = rtrim($cleaned) . "\n\n#tryinclude \"/etc/asterisk/musiconhold.custom.conf\"\n"; + } + if ($cleaned !== $content) { + file_put_contents($mohConf, $cleaned); + } + } + + $vmConf = '/etc/asterisk/voicemail.conf'; + if (is_file($vmConf) && is_writable($vmConf)) { + $content = (string)file_get_contents($vmConf); + $cleaned = preg_replace('/^1234 => 4242.*/m', ';1234 => 4242,Example Mailbox,root@localhost', $content); + $cleaned = preg_replace('/^;\s*passwordlocation\s*=\s*spooldir/m', 'passwordlocation=spooldir', $cleaned); + $cleaned = preg_replace('/^;\s*externpassnotify\s*=.*/m', 'externpassnotify=/usr/bin/php /var/www/nexus-pbx/scripts/voicemail_pass_sync.php', $cleaned); + if (!str_contains($cleaned, 'voicemail.custom.conf')) { + $cleaned = rtrim($cleaned) . "\n\n#tryinclude \"voicemail.custom.conf\"\n"; + } + if ($cleaned !== $content) { + file_put_contents($vmConf, $cleaned); + } + } + + $astConf = '/etc/asterisk/asterisk.conf'; + if (is_file($astConf) && is_writable($astConf)) { + $content = (string)file_get_contents($astConf); + $lang = 'fr'; + try { + $db = Database::getConnection(); + $s = $db->query('SELECT system_language FROM settings WHERE id = 1')->fetch(); + if (!empty($s['system_language'])) { + $lang = trim((string)$s['system_language']); + } + } catch (\Throwable $e) {} + if (preg_match('/^;?\s*defaultlanguage\s*=/m', $content)) { + $cleaned = preg_replace('/^;?\s*defaultlanguage\s*=.*$/m', "defaultlanguage = {$lang}", $content); + } else { + $cleaned = preg_replace('/\[options\]/i', "[options]\ndefaultlanguage = {$lang}", $content); + } + if ($cleaned !== $content) { + file_put_contents($astConf, $cleaned); + } + } + } +} + diff --git a/src/SystemMetrics.php b/src/SystemMetrics.php new file mode 100644 index 0000000..7b58c47 --- /dev/null +++ b/src/SystemMetrics.php @@ -0,0 +1,403 @@ + 0) { + $cpuPercent = round(max(0.0, min(100.0, (1.0 - ($dIdle / $dTotal)) * 100.0)), 1); + } + } + + $loadAvg = sys_getloadavg() ?: [0.0, 0.0, 0.0]; + $cpuCores = 1; + $cpuModel = 'Processeur Debian'; + if (is_readable('/proc/cpuinfo')) { + $cpuinfo = (string)file_get_contents('/proc/cpuinfo'); + $cpuCores = max(1, substr_count($cpuinfo, 'processor')); + if (preg_match('/model name\s*:\s*(.+)/i', $cpuinfo, $m)) { + $cpuModel = trim($m[1]); + } + } + + // 2. Mémoire RAM & Swap (/proc/meminfo) + $mem = self::readMemInfo(); + + // 3. Disque HDD/SSD (/) + $diskTotal = (float)(@disk_total_space('/') ?: 1); + $diskFree = (float)(@disk_free_space('/') ?: 0); + $diskUsed = max(0.0, $diskTotal - $diskFree); + $diskPct = round(($diskUsed / $diskTotal) * 100.0, 1); + + // 4. Réseau (/proc/net/dev) + $net = self::readNetDev(); + $rxRateKbps = 0.0; + $txRateKbps = 0.0; + if (isset($prev['net'], $prev['timestamp']) && ($now - $prev['timestamp']) > 0.2) { + $dt = $now - $prev['timestamp']; + $dRx = max(0, $net['rx_bytes'] - $prev['net']['rx_bytes']); + $dTx = max(0, $net['tx_bytes'] - $prev['net']['tx_bytes']); + // En Ko/s (KB/s) + $rxRateKbps = round(($dRx / $dt) / 1024, 2); + $txRateKbps = round(($dTx / $dt) / 1024, 2); + } + + // Sauvegarde de l'échantillon pour le calcul différentiel CPU & Réseau + self::savePrevSample([ + 'timestamp' => $now, + 'cpu' => $cpuStat, + 'net' => $net, + ]); + + // 5. Uptime Debian (/proc/uptime) + $uptimeSeconds = 0; + if (is_readable('/proc/uptime')) { + $parts = explode(' ', trim((string)file_get_contents('/proc/uptime'))); + $uptimeSeconds = (int)floor((float)($parts[0] ?? 0)); + } + + // 6. Version Debian & État temps réel Asterisk via AMI (Postes enregistrés, latence RTT, appels actifs) + $debianVer = is_readable('/etc/debian_version') ? trim((string)file_get_contents('/etc/debian_version')) : '13'; + $asteriskInfo = self::readAsteriskLiveStatus($db); + + // 7. Statut des Services Debian & Fail2ban / Pare-feu + $fwStatus = FirewallManager::getStatus(); + $services = self::readServicesStatus($asteriskInfo, $fwStatus); + + return [ + 'timestamp' => date('H:i:s'), + 'hostname' => gethostname() ?: 'debian-pbx', + 'os_version' => "Debian {$debianVer}", + 'uptime' => self::formatDuration($uptimeSeconds), + 'cpu' => [ + 'percent' => $cpuPercent, + 'cores' => $cpuCores, + 'model' => $cpuModel, + 'load_1' => round((float)$loadAvg[0], 2), + 'load_5' => round((float)$loadAvg[1], 2), + 'load_15' => round((float)$loadAvg[2], 2), + ], + 'memory' => $mem, + 'disk' => [ + 'total_gb' => round($diskTotal / 1073741824, 2), + 'used_gb' => round($diskUsed / 1073741824, 2), + 'free_gb' => round($diskFree / 1073741824, 2), + 'percent' => $diskPct, + ], + 'network' => [ + 'interface' => $net['interface'], + 'ip' => $net['ip'], + 'rx_kbps' => $rxRateKbps, + 'tx_kbps' => $txRateKbps, + 'rx_total_mb' => round($net['rx_bytes'] / 1048576, 1), + 'tx_total_mb' => round($net['tx_bytes'] / 1048576, 1), + ], + 'asterisk' => $asteriskInfo, + 'services' => $services, + 'security' => [ + 'firewall_enabled' => (bool)($fwStatus['settings']['firewall_enabled'] ?? false), + 'fail2ban_running' => (bool)($fwStatus['is_running'] ?? false), + 'admin_port' => (int)($fwStatus['settings']['admin_https_port'] ?? 8443), + 'strict_port80' => (bool)($fwStatus['settings']['certbot_strict_port80'] ?? false), + 'total_banned' => (int)($fwStatus['total_banned'] ?? 0), + 'banned_ips' => $fwStatus['banned_ips'] ?? [], + 'jails' => $fwStatus['jails'] ?? [], + ], + ]; + } + + private static function readServicesStatus(array $astInfo, array $fwStatus): array + { + $out = trim((string)@shell_exec('systemctl is-active asterisk nginx php8.4-fpm fail2ban 2>/dev/null')); + $lines = preg_split('/\r?\n/', $out) ?: []; + + $astActive = ($lines[0] ?? '') === 'active' || ($astInfo['uptime'] ?? '') !== 'Hors-ligne'; + $nginxActive = ($lines[1] ?? '') === 'active'; + $phpActive = ($lines[2] ?? '') === 'active'; + $f2bActive = ($lines[3] ?? '') === 'active' || !empty($fwStatus['is_running']); + $isF2bRunning = !empty($fwStatus['is_running']) && (($lines[3] ?? '') === 'active'); + $fwEnabled = !empty($fwStatus['settings']['firewall_enabled']) && $isF2bRunning; + + $adminPort = $fwStatus['settings']['admin_https_port'] ?? 8443; + + return [ + [ + 'id' => 'asterisk', + 'name' => 'Asterisk 22 PBX', + 'desc' => 'Moteur SIP PJSIP & Dialplan', + 'port' => '5060 / 5061', + 'active' => $astActive, + 'status_text' => $astActive ? 'Opérationnel' : 'Arrêté', + 'uptime' => $astInfo['uptime'] ?? 'Actif', + ], + [ + 'id' => 'nginx', + 'name' => 'Serveur Web Nginx', + 'desc' => 'Portail GUI & Reverse Proxy', + 'port' => "80 / 443 / {$adminPort}", + 'active' => $nginxActive, + 'status_text' => $nginxActive ? 'Opérationnel' : 'Arrêté', + 'uptime' => 'En ligne', + ], + [ + 'id' => 'php', + 'name' => 'PHP 8.4 FPM', + 'desc' => 'API FastCGI & SQLite', + 'port' => 'Unix Socket', + 'active' => $phpActive, + 'status_text' => $phpActive ? 'Opérationnel' : 'Arrêté', + 'uptime' => 'En ligne', + ], + [ + 'id' => 'fail2ban', + 'name' => 'Fail2ban IDS / IPS', + 'desc' => 'Jails [asterisk, web, ssh]', + 'port' => 'Surveillance logs', + 'active' => $f2bActive, + 'status_text' => $f2bActive ? 'En surveillance' : 'Arrêté', + 'uptime' => $f2bActive ? 'Actif' : 'Arrêté', + ], + [ + 'id' => 'firewall', + 'name' => 'Pare-feu & Filtrage', + 'desc' => $fwEnabled ? 'Règles actives & Isolation 80' : 'Inactif (Tout autoriser)', + 'port' => 'Filtrage IP & Nginx', + 'active' => $fwEnabled, + 'status_text' => $fwEnabled ? 'Filtrage Actif' : 'Inactif', + 'uptime' => $fwEnabled ? 'Actif' : 'Inactif', + ], + ]; + } + + private static function readCpuStat(): array + { + if (!is_readable('/proc/stat')) { + return ['total' => 0, 'idle' => 0]; + } + $firstLine = fgets(fopen('/proc/stat', 'r')) ?: ''; + $parts = preg_split('/\s+/', trim($firstLine)) ?: []; + array_shift($parts); // retire "cpu" + $vals = array_map('intval', $parts); + $idle = ($vals[3] ?? 0) + ($vals[4] ?? 0); // idle + iowait + $total = array_sum($vals); + return ['total' => $total, 'idle' => $idle]; + } + + private static function readMemInfo(): array + { + $data = []; + if (is_readable('/proc/meminfo')) { + foreach (file('/proc/meminfo') ?: [] as $line) { + if (preg_match('/^(\w+):\s+(\d+)/', $line, $m)) { + $data[$m[1]] = (int)$m[2]; // en kB + } + } + } + $totalKb = max(1, $data['MemTotal'] ?? 1); + $availKb = $data['MemAvailable'] ?? (($data['MemFree'] ?? 0) + ($data['Buffers'] ?? 0) + ($data['Cached'] ?? 0)); + $usedKb = max(0, $totalKb - $availKb); + $swapTotalKb = $data['SwapTotal'] ?? 0; + $swapFreeKb = $data['SwapFree'] ?? 0; + $swapUsedKb = max(0, $swapTotalKb - $swapFreeKb); + + return [ + 'total_mb' => (int)round($totalKb / 1024), + 'used_mb' => (int)round($usedKb / 1024), + 'free_mb' => (int)round($availKb / 1024), + 'percent' => round(($usedKb / $totalKb) * 100.0, 1), + 'swap_total_mb' => (int)round($swapTotalKb / 1024), + 'swap_used_mb' => (int)round($swapUsedKb / 1024), + ]; + } + + private static function readNetDev(): array + { + $iface = 'eth0'; + $rxBytes = 0; + $txBytes = 0; + + if (is_readable('/proc/net/dev')) { + $lines = file('/proc/net/dev') ?: []; + foreach ($lines as $line) { + if (!str_contains($line, ':')) { + continue; + } + [$name, $rest] = explode(':', $line, 2); + $name = trim($name); + if ($name === 'lo') { + continue; + } + $cols = preg_split('/\s+/', trim($rest)) ?: []; + $iface = $name; + $rxBytes = (int)($cols[0] ?? 0); + $txBytes = (int)($cols[8] ?? 0); + break; + } + } + + $ip = $_SERVER['SERVER_ADDR'] ?? ''; + if ($ip === '' || $ip === '127.0.0.1') { + $out = @shell_exec("ip -4 -o addr show dev " . escapeshellarg($iface) . " 2>/dev/null"); + if (is_string($out) && preg_match('/inet\s+([0-9.]+)/', $out, $m)) { + $ip = $m[1]; + } + } + + return [ + 'interface' => $iface, + 'ip' => $ip ?: '127.0.0.1', + 'rx_bytes' => $rxBytes, + 'tx_bytes' => $txBytes, + ]; + } + + private static function readAsteriskLiveStatus(PDO $db): array + { + $contacts = []; + $activeCalls = 0; + $astUptime = 'Actif'; + $astVersion = 'Asterisk 22 • PJSIP'; + $onlineCount = 0; + + try { + $ami = AmiClient::fromDatabase($db); + $rawVer = $ami->runCommand('core show version'); + if (preg_match('/Asterisk\s+([0-9]+\.[0-9]+(?:\.[0-9]+)?)/i', $rawVer, $vm)) { + $astVersion = "Asterisk {$vm[1]} • PJSIP"; + } + $rawContacts = $ami->runCommand('pjsip show contacts'); + $astDbRaw = $ami->runCommand('database show registrar/contact'); + $contactMeta = []; + foreach (preg_split('/\r?\n/', $astDbRaw) ?: [] as $dbLine) { + if (preg_match('/^\/registrar\/contact\/([^;]+);@[a-f0-9]+:\s*(\{.*\})/i', $dbLine, $dbM)) { + $extKey = $dbM[1]; + $parsedJson = json_decode($dbM[2], true); + if ($parsedJson) { + $contactMeta[$extKey] = $parsedJson; + } + } + } + + foreach (preg_split('/\r?\n/', $rawContacts) ?: [] as $line) { + // Contact: 101/sip:101@10.10.167.162:5060 5dd9a21be8 Avail 9.234 + // or with -nan: Contact: 102/sip:102@10.10.168.228:5060;x-ast-txp=trans bb0686ce80 Unavail -nan + if (preg_match('/Contact:\s+([^\s\/]+)\/sip:([^\s]+)\s+([a-f0-9]+)\s+(\S+)\s+(-?[0-9.]+|nan|-nan)/i', $line, $m)) { + $extNum = $m[1]; + $uri = $m[2]; + $status = $m[4]; + $isAvail = (stripos($status, 'Avail') !== false && stripos($status, 'Unavail') === false) || stripos($status, 'NonQual') !== false; + if ($isAvail) { + $onlineCount++; + } + + $meta = $contactMeta[$extNum] ?? []; + $fullUri = $meta['uri'] ?? ("sip:" . $uri); + $userAgent = $meta['user_agent'] ?? ''; + + // Détection précise du transport utilisé en direct + $activeTransport = 'udp'; + if (preg_match('/transport=(tls|wss|ws|tcp|udp)/i', $fullUri, $tm)) { + $activeTransport = strtolower($tm[1]); + } elseif (preg_match('/x-ast-txp=transport-(tls|wss|ws|tcp|udp)/i', $fullUri, $tm)) { + $activeTransport = strtolower($tm[1]); + } elseif (str_starts_with($fullUri, 'sips:')) { + $activeTransport = 'tls'; + } + + $contacts[] = [ + 'extension' => $extNum, + 'uri' => $fullUri, + 'user_agent' => $userAgent, + 'active_transport' => $activeTransport, + 'status' => $status, + 'rtt_ms' => is_numeric($m[5]) ? round((float)$m[5], 1) : null, + ]; + } + } + + $rawChannels = $ami->runCommand('core show channels count'); + if (preg_match('/(\d+)\s+active\s+call/i', $rawChannels, $cm)) { + $activeCalls = (int)$cm[1]; + } + + $rawUptime = $ami->runCommand('core show uptime'); + if (preg_match('/System uptime:\s*(.+)/i', $rawUptime, $um)) { + $astUptime = trim($um[1]); + } + + $registrations = []; + $rawRegs = $ami->runCommand('pjsip show registrations'); + foreach (preg_split('/\r?\n/', $rawRegs) ?: [] as $line) { + // trunk-ovh-reg/sip:siptrunk.ovh.net:5060 trunk-ovh-auth Registered + if (preg_match('/^\s*(trunk-[^\s\/]+)-reg\/(\S+)\s+(\S+)\s+(\S+)/i', $line, $rm)) { + $registrations[$rm[1]] = [ + 'server_uri' => $rm[2], + 'auth' => $rm[3], + 'status' => $rm[4], + ]; + } + } + } catch (Throwable) { + $astUptime = 'Hors-ligne'; + $registrations = []; + } + + return [ + 'version' => $astVersion, + 'online_contacts' => $onlineCount, + 'active_calls' => $activeCalls, + 'uptime' => $astUptime, + 'contacts' => $contacts, + 'registrations' => $registrations ?? [], + ]; + } + + private static function loadPrevSample(): array + { + if (!is_readable(self::CACHE_FILE)) { + return []; + } + $raw = @file_get_contents(self::CACHE_FILE); + return $raw ? (json_decode($raw, true) ?: []) : []; + } + + private static function savePrevSample(array $sample): void + { + @file_put_contents(self::CACHE_FILE, json_encode($sample)); + } + + private static function formatDuration(int $seconds): string + { + $days = intdiv($seconds, 86400); + $hours = intdiv($seconds % 86400, 3600); + $mins = intdiv($seconds % 3600, 60); + if ($days > 0) { + return "{$days}j {$hours}h {$mins}m"; + } + if ($hours > 0) { + return "{$hours}h {$mins}m"; + } + return "{$mins} min"; + } +} + diff --git a/storage/backups/.gitkeep b/storage/backups/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/storage/sounds/.gitkeep b/storage/sounds/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/storage/system_backups/.gitkeep b/storage/system_backups/.gitkeep new file mode 100644 index 0000000..e69de29