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); self::migratePhonebookContactsTable($db); self::migratePagingGroupsTable($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), ]); } } 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 'terminate-hangup', 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 = [ 'registration_mode' => "VARCHAR(16) NOT NULL DEFAULT 'send'", '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", 'cid_name_prefix' => "VARCHAR(64) NOT NULL DEFAULT ''", 'dial_options' => "VARCHAR(32) NOT NULL DEFAULT 'tTR'", ]; foreach ($extraColumns as $colName => $colDef) { if (!in_array($colName, $existing, true)) { $db->exec("ALTER TABLE trunks ADD COLUMN {$colName} {$colDef}"); if ($colName === 'registration_mode') { $db->exec("UPDATE trunks SET registration_mode = CASE WHEN register = 1 THEN 'send' ELSE 'none' END"); } } } } private static function migrateOutboundRoutesTable(PDO $db): void { // Aucun enregistrement de démonstration auto-injecté pour conserver une base propre } private static function migrateExtensionGroupsTable(PDO $db): void { // Aucun enregistrement de démonstration auto-injecté pour conserver une base propre } private static function migrateSoundsTable(PDO $db): void { // Aucun enregistrement de démonstration auto-injecté pour conserver une base propre } 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']); } } 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], ]); } } } private static function migratePhonebookContactsTable(PDO $db): void { $db->exec(" CREATE TABLE IF NOT EXISTS phonebook_contacts ( id INTEGER PRIMARY KEY AUTOINCREMENT, extension_id INTEGER DEFAULT NULL, display_name VARCHAR(128) NOT NULL, internal_number VARCHAR(32) NOT NULL DEFAULT '', mobile_number VARCHAR(32) NOT NULL DEFAULT '', other_number VARCHAR(32) NOT NULL DEFAULT '', email VARCHAR(128) NOT NULL DEFAULT '', photo_url TEXT NOT NULL DEFAULT '', group_name VARCHAR(64) NOT NULL DEFAULT 'Général', is_internal INTEGER NOT NULL DEFAULT 1, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX IF NOT EXISTS idx_phonebook_ext ON phonebook_contacts(extension_id); CREATE INDEX IF NOT EXISTS idx_phonebook_group ON phonebook_contacts(group_name); "); } private static function migratePagingGroupsTable(PDO $db): void { $db->exec(" CREATE TABLE IF NOT EXISTS paging_groups ( id INTEGER PRIMARY KEY AUTOINCREMENT, extension VARCHAR(16) NOT NULL UNIQUE, name VARCHAR(64) NOT NULL, description VARCHAR(255) NOT NULL DEFAULT '', members TEXT NOT NULL DEFAULT '[]', duplex INTEGER NOT NULL DEFAULT 0, busy_mode VARCHAR(16) NOT NULL DEFAULT 'skip', play_beep INTEGER NOT NULL DEFAULT 1, timeout INTEGER NOT NULL DEFAULT 120, enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)), created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ); "); // Aucun groupe de paging par défaut pour conserver une base propre } }