Compare commits

..
18 Commits
Author SHA1 Message Date
sorcier 860594fded chore(branding): update tagline to Powered by Asterisk across login and sidebar 2026-11-05 00:12:27 +01:00
sorcier c0760ec52a style(ui): enlarge standalone logo on sidebar and login modal 2026-11-04 23:47:38 +01:00
sorcier 2d4c80a7ec style(ui): display standalone full-size logo without bounding box 2026-11-04 23:43:09 +01:00
sorcier 2eef15458c feat(ui): integrate official NexusPBX logo and favicon (replace placeholder icon) 2026-11-04 23:40:14 +01:00
sorcier 08837686e3 chore(install): ensure CDR directory, Master.csv and ACL permissions in install and update scripts 2026-11-04 18:20:24 +01:00
sorcier e6cd08ab4e fix(cdr): remove redundant Auth method calls in CDR actions 2026-11-04 14:57:03 +01:00
sorcier 21a88440c3 fix(cdr): pass filter parameters as payload object in apiCall 2026-11-04 14:47:35 +01:00
sorcier 7c28a237ba feat(cdr): add Call Logs & CDR module with automatic trunk detection and CSV export (v1.6.0) 2026-11-04 14:42:29 +01:00
sorcier a9d86dd6ac fix(tls): support TLS 1.2/1.3 and fix client certificate request for Yealink 2026-11-04 13:29:38 +01:00
sorcier eadba4af3f v1.5.2: Exécution Certbot ACME avec sudo et sécurisation des droits Let's Encrypt 2026-11-04 13:04:06 +01:00
sorcier e3f22e8e0b Nettoyage radical: Rollback complet de check-sync et AstDB. Seul maximum_expiration/default_expiration est conservé 2026-11-04 12:53:01 +01:00
sorcier 40e36e0423 Nettoyage: Suppression de la persistance AstDB et adoption exclusive du standard SIP maximum_expiration pour la reconnexion automatique 2026-11-04 12:48:50 +01:00
sorcier 65a62ca0e4 v1.5.1: Sauvegarde/restauration intégrale AstDB et synchronisation SIP immédiate des téléphones (check-sync) 2026-11-04 12:43:32 +01:00
sorcier accec47d08 feat(sounds): absolute audio paths for custom sounds making IVRs fully language-agnostic (v1.5.0) 2026-11-04 12:09:32 +01:00
sorcier 6c74e09203 fix(sounds): link custom sounds in language directories for Asterisk (v1.4.9) 2026-11-04 12:04:19 +01:00
sorcier 8f4ea50452 fix(ivr): resolve dialplan conflict on *801-*899 and link debian sound prompts (v1.4.8) 2026-11-04 11:51:21 +01:00
sorcier 1e288af617 fix(metrics): robust contact detection with AstDB fallback and reset f2b counters on clear log (v1.4.7) 2026-11-04 11:33:08 +01:00
sorcier bda4cb18a6 fix(voicemail): automate modules.conf and voicemail includes in install.sh and fix.sh (v1.4.6) 2026-11-04 11:06:04 +01:00
14 changed files with 1229 additions and 77 deletions

No files matched your search

+58 -15
View File
@@ -88,8 +88,8 @@ if ! apt-get install -y asterisk; then
apt-get install -y -t sid asterisk
fi
# Sons Asterisk en français (exacts de la machine de dev)
apt-get install -y asterisk-core-sounds-fr asterisk-core-sounds-fr-wav asterisk-core-sounds-fr-gsm 2>/dev/null || true
# Sons Asterisk en français et anglais
apt-get install -y asterisk-core-sounds-fr asterisk-core-sounds-fr-wav asterisk-core-sounds-fr-gsm asterisk-core-sounds-en asterisk-core-sounds-en-gsm 2>/dev/null || true
# Nginx, PHP-FPM et extensions requises
apt-get install -y --no-install-recommends \
@@ -159,12 +159,12 @@ mkdir -p "$TARGET_DIR/public/avatars"
mkdir -p /etc/asterisk/keys
mkdir -p /var/lib/asterisk/sounds/custom
mkdir -p /var/spool/asterisk/voicemail/default
mkdir -p /var/log/asterisk
mkdir -p /var/log/asterisk /var/log/asterisk/cdr-csv
mkdir -p /var/log/nexus-pbx
touch /var/log/asterisk/messages /var/log/asterisk/security.log
chown asterisk:asterisk /var/log/asterisk/messages /var/log/asterisk/security.log 2>/dev/null || true
chmod 0664 /var/log/asterisk/messages /var/log/asterisk/security.log 2>/dev/null || true
touch /var/log/asterisk/messages /var/log/asterisk/security.log /var/log/asterisk/cdr-csv/Master.csv
chown -R asterisk:asterisk /var/log/asterisk 2>/dev/null || true
chmod 0664 /var/log/asterisk/messages /var/log/asterisk/security.log /var/log/asterisk/cdr-csv/Master.csv 2>/dev/null || true
touch /var/log/nexus_security.log
chown www-data:asterisk /var/log/nexus_security.log 2>/dev/null || true
@@ -180,11 +180,11 @@ chmod 2775 "$TARGET_DIR/storage" \
mkdir -p /var/lib/asterisk/sounds/custom /var/lib/asterisk/moh /var/spool/asterisk/voicemail
chown -R asterisk:asterisk /etc/asterisk /var/lib/asterisk /var/spool/asterisk /var/log/asterisk
chmod -R 775 /etc/asterisk /var/lib/asterisk/sounds /var/lib/asterisk/moh /var/spool/asterisk
chmod 2775 /etc/asterisk /var/lib/asterisk/sounds /var/lib/asterisk/sounds/custom /var/lib/asterisk/moh /var/spool/asterisk
chmod -R 775 /etc/asterisk /var/lib/asterisk/sounds /var/lib/asterisk/moh /var/spool/asterisk /var/log/asterisk
chmod 2775 /etc/asterisk /var/lib/asterisk/sounds /var/lib/asterisk/sounds/custom /var/lib/asterisk/moh /var/spool/asterisk /var/log/asterisk /var/log/asterisk/cdr-csv
chmod 2750 /etc/asterisk/keys
setfacl -R -m u:www-data:rwx,g:asterisk:rwx /etc/asterisk /var/lib/asterisk/sounds /var/lib/asterisk/moh /var/spool/asterisk 2>/dev/null || true
setfacl -R -d -m u:www-data:rwx,g:asterisk:rwx /etc/asterisk /var/lib/asterisk/sounds /var/lib/asterisk/moh /var/spool/asterisk 2>/dev/null || true
setfacl -R -m u:www-data:rwx,g:asterisk:rwx /etc/asterisk /var/lib/asterisk/sounds /var/lib/asterisk/moh /var/spool/asterisk /var/log/asterisk 2>/dev/null || true
setfacl -R -d -m u:www-data:rwx,g:asterisk:rwx /etc/asterisk /var/lib/asterisk/sounds /var/lib/asterisk/moh /var/spool/asterisk /var/log/asterisk 2>/dev/null || true
# Droits sudoers pour l'interface web (www-data)
cat << 'EOF' > /etc/sudoers.d/nexus-pbx
@@ -201,12 +201,17 @@ for fpm_svc in /lib/systemd/system/php*-fpm.service /usr/lib/systemd/system/php*
cat << 'EOF_OVERRIDE' > "/etc/systemd/system/${svc_name}.d/override.conf"
[Service]
ProtectSystem=false
ReadWritePaths=/etc/asterisk /etc/fail2ban /etc/nginx /var/log /var/lib/asterisk /var/spool/asterisk /var/www /usr/local/bin /etc/sudoers.d
ReadWritePaths=/etc/asterisk /etc/fail2ban /etc/nginx /var/log /var/lib/asterisk /var/spool/asterisk /var/www /usr/local/bin /etc/sudoers.d /etc/letsencrypt /var/lib/letsencrypt
EOF_OVERRIDE
fi
done
systemctl daemon-reload
# Répertoires Let's Encrypt et permissions pour Asterisk / Web
mkdir -p /etc/letsencrypt /var/log/letsencrypt /var/lib/letsencrypt
chgrp -R asterisk /etc/letsencrypt 2>/dev/null || true
chmod -R g+rX /etc/letsencrypt 2>/dev/null || true
# Configuration des limites d'upload PHP (Restauration d'archives 512 Mo & Sons volumineux)
for php_dir in /etc/php/*; do
if [ -d "$php_dir/fpm/conf.d" ]; then
@@ -287,23 +292,61 @@ touch /etc/asterisk/pjsip.conf \
/etc/asterisk/pjsip.custom.conf \
/etc/asterisk/extensions.conf \
/etc/asterisk/extensions.custom.conf \
/etc/asterisk/musiconhold.conf \
/etc/asterisk/musiconhold.custom.conf \
/etc/asterisk/voicemail.conf \
/etc/asterisk/voicemail.custom.conf \
/etc/asterisk/modules.conf \
/etc/asterisk/rtp.conf \
/etc/asterisk/features.conf
# 1. Modules Asterisk : Désactivation des modules ODBC/IMAP incompatibles sur Debian (évite conflit bloquant app_voicemail)
if ! grep -q 'noload => app_voicemail_odbc.so' /etc/asterisk/modules.conf 2>/dev/null; then
if grep -q '\[modules\]' /etc/asterisk/modules.conf 2>/dev/null; then
sed -i '/\[modules\]/a noload => app_voicemail_odbc.so\nnoload => app_voicemail_imap.so' /etc/asterisk/modules.conf 2>/dev/null || true
else
printf '[modules]\nautoload=yes\nnoload => app_voicemail_odbc.so\nnoload => app_voicemail_imap.so\n' >> /etc/asterisk/modules.conf
fi
fi
# 2. Inclusions des fichiers custom dans les configurations principales
if ! grep -q 'pjsip.custom.conf' /etc/asterisk/pjsip.conf 2>/dev/null; then
printf '\n#tryinclude "pjsip.custom.conf"\n' >> /etc/asterisk/pjsip.conf
fi
if ! grep -q 'extensions.custom.conf' /etc/asterisk/extensions.conf 2>/dev/null; then
printf '\n#tryinclude "extensions.custom.conf"\n' >> /etc/asterisk/extensions.conf
fi
chown -R asterisk:asterisk /etc/asterisk
if ! grep -q 'musiconhold.custom.conf' /etc/asterisk/musiconhold.conf 2>/dev/null; then
sed -i 's/^\[default\]/;[default]/' /etc/asterisk/musiconhold.conf 2>/dev/null || true
sed -i 's/^mode=files/;mode=files/' /etc/asterisk/musiconhold.conf 2>/dev/null || true
sed -i 's/^directory=moh/;directory=moh/' /etc/asterisk/musiconhold.conf 2>/dev/null || true
printf '\n#tryinclude "/etc/asterisk/musiconhold.custom.conf"\n' >> /etc/asterisk/musiconhold.conf
fi
if ! grep -q 'voicemail.custom.conf' /etc/asterisk/voicemail.conf 2>/dev/null; then
sed -i 's/^1234 => 4242/;1234 => 4242/' /etc/asterisk/voicemail.conf 2>/dev/null || true
sed -i 's/^;\s*passwordlocation\s*=\s*spooldir/passwordlocation=spooldir/' /etc/asterisk/voicemail.conf 2>/dev/null || true
sed -i 's|^;\s*externpassnotify\s*=.*|externpassnotify=/usr/bin/php /var/www/nexus-pbx/scripts/voicemail_pass_sync.php|' /etc/asterisk/voicemail.conf 2>/dev/null || true
printf '\n#tryinclude "voicemail.custom.conf"\n' >> /etc/asterisk/voicemail.conf
fi
mkdir -p /var/spool/asterisk/voicemail/default /var/lib/asterisk/sounds/custom /var/lib/asterisk/moh
for lang in fr en es; do
if [ -d "/usr/share/asterisk/sounds/$lang" ] && [ ! -e "/var/lib/asterisk/sounds/$lang" ]; then
ln -sf "/usr/share/asterisk/sounds/$lang" "/var/lib/asterisk/sounds/$lang"
fi
if [ -d "/var/lib/asterisk/sounds/$lang" ] && [ ! -e "/var/lib/asterisk/sounds/$lang/custom" ]; then
ln -sf /var/lib/asterisk/sounds/custom "/var/lib/asterisk/sounds/$lang/custom" 2>/dev/null || true
fi
done
if [ ! -e "/usr/share/asterisk/sounds/custom" ]; then
ln -sf /var/lib/asterisk/sounds/custom /usr/share/asterisk/sounds/custom 2>/dev/null || true
fi
chown -R asterisk:asterisk /etc/asterisk /var/spool/asterisk /var/lib/asterisk
chmod 0664 /etc/asterisk/*.conf /etc/asterisk/*.custom.conf 2>/dev/null || true
chmod 2750 /etc/asterisk/keys
chmod 2775 /etc/asterisk
setfacl -R -m u:www-data:rwx,g:asterisk:rwx /etc/asterisk 2>/dev/null || true
setfacl -R -d -m u:www-data:rwx,g:asterisk:rwx /etc/asterisk 2>/dev/null || true
chmod 2775 /etc/asterisk /var/spool/asterisk /var/spool/asterisk/voicemail /var/lib/asterisk/sounds /var/lib/asterisk/moh
setfacl -R -m u:www-data:rwx,g:asterisk:rwx /etc/asterisk /var/spool/asterisk /var/lib/asterisk 2>/dev/null || true
setfacl -R -d -m u:www-data:rwx,g:asterisk:rwx /etc/asterisk /var/spool/asterisk /var/lib/asterisk 2>/dev/null || true
# Rechargement Asterisk immédiat pour appliquer AMI et Logger
asterisk -rx "core reload" 2>/dev/null || true
+44 -2
View File
@@ -11,6 +11,7 @@ require_once __DIR__ . '/../src/CertificateManager.php';
require_once __DIR__ . '/../src/BackupManager.php';
require_once __DIR__ . '/../src/FirewallManager.php';
require_once __DIR__ . '/../src/PhonebookManager.php';
require_once __DIR__ . '/../src/CdrManager.php';
use App\Database;
use App\Auth;
@@ -22,6 +23,7 @@ use App\CertificateManager;
use App\BackupManager;
use App\FirewallManager;
use App\PhonebookManager;
use App\CdrManager;
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store, no-cache, must-revalidate');
@@ -140,7 +142,7 @@ try {
$installedVerData = file_exists("{$appDir}/version.json")
? json_decode((string)file_get_contents("{$appDir}/version.json"), true)
: [];
$installedVer = $installedVerData['version'] ?? '1.4.5';
$installedVer = $installedVerData['version'] ?? '1.5.0';
echo json_encode([
'success' => true,
@@ -227,7 +229,7 @@ try {
@chmod($webLog, 0664);
}
@unlink('/tmp/nexus_fw_status_cache.json');
exec('sudo /usr/bin/fail2ban-client reload 2>&1');
exec('sudo /usr/bin/systemctl restart fail2ban 2>&1 || sudo /usr/bin/fail2ban-client reload 2>&1');
echo json_encode([
'success' => true,
@@ -2276,6 +2278,46 @@ try {
echo json_encode($res);
break;
case 'get_cdr':
$filters = [
'limit' => (int)($payload['limit'] ?? $_GET['limit'] ?? 100),
'direction' => (string)($payload['direction'] ?? $_GET['direction'] ?? 'all'),
'disposition' => (string)($payload['disposition'] ?? $_GET['disposition'] ?? 'all'),
'period' => (string)($payload['period'] ?? $_GET['period'] ?? 'all'),
'search' => (string)($payload['search'] ?? $_GET['search'] ?? ''),
];
$cdrData = CdrManager::getCalls($db, $filters);
echo json_encode([
'success' => true,
'calls' => $cdrData['calls'],
'stats' => $cdrData['stats'],
'total_records' => $cdrData['total_records'],
]);
break;
case 'export_cdr_csv':
$filters = [
'direction' => (string)($_GET['direction'] ?? 'all'),
'disposition' => (string)($_GET['disposition'] ?? 'all'),
'period' => (string)($_GET['period'] ?? 'all'),
'search' => (string)($_GET['search'] ?? ''),
];
$csv = CdrManager::exportCsv($db, $filters);
header('Content-Type: text/csv; charset=utf-8');
header('Content-Disposition: attachment; filename="nexuspbx_journal_appels_' . date('Y-m-d_His') . '.csv"');
header('Pragma: no-cache');
header('Expires: 0');
echo $csv;
exit;
case 'clear_cdr':
$cleared = CdrManager::clearCdr();
echo json_encode([
'success' => $cleared,
'message' => $cleared ? "Journal d'appels réinitialisé avec succès." : "Impossible de vider le fichier CDR.",
]);
break;
case 'check_update':
$appDir = dirname(__DIR__);
clearstatcache(true, "{$appDir}/version.json");
+236
View File
@@ -3508,6 +3508,7 @@ function renderSettingsAndDiagnostics() {
// --- Navigation Sidebar & Modales ---
const PAGE_TITLES = {
dashboard: 'Tableau de Bord & Supervision Temps Réel',
cdr: 'Journal d\'Appels & Historique CDR (Call Detail Records)',
extensions: 'Postes Téléphoniques SIP (PJSIP)',
groups: "Groupes de Sonnerie (Ring Groups)",
extgroups: "Groupes d'Extensions (Sites & Droits Sortants)",
@@ -3586,6 +3587,11 @@ function switchTab(tabId) {
loadBackups();
}
// Si onglet Journal d'appels, charger l'historique CDR
if (tabId === 'cdr') {
loadCdr();
}
// Si onglet Pare-feu, charger le statut en direct
if (tabId === 'firewall') {
loadFirewallStatus();
@@ -6073,3 +6079,233 @@ async function executeSystemUpdate() {
}
}
}
// =========================================================================
// JOURNAL D'APPELS (CDR) & HISTORIQUE DES COMMUNICATIONS
// =========================================================================
let cdrDebounceTimer = null;
function debounceCdrLoad() {
clearTimeout(cdrDebounceTimer);
cdrDebounceTimer = setTimeout(() => {
loadCdr();
}, 300);
}
async function loadCdr() {
const tbody = document.getElementById('cdr-table-body');
const info = document.getElementById('cdr-status-info');
const spinner = document.getElementById('spinner-refresh-cdr');
if (spinner) spinner.classList.add('animate-spin');
const search = document.getElementById('cdr-filter-search')?.value || '';
const period = document.getElementById('cdr-filter-period')?.value || 'today';
const direction = document.getElementById('cdr-filter-direction')?.value || 'all';
const disposition = document.getElementById('cdr-filter-disposition')?.value || 'all';
const limit = document.getElementById('cdr-filter-limit')?.value || 100;
try {
const res = await apiCall('get_cdr', {
search,
period,
direction,
disposition,
limit
});
if (!res || !res.success) throw new Error(res?.message || 'Erreur lors de la récupération des CDR');
renderCdrStats(res.stats || {});
renderCdrTable(res.calls || []);
if (info) {
const count = (res.calls || []).length;
const total = res.total_records || 0;
info.textContent = `Affichage de ${count} appel(s) sur ${total} analysé(s) pour la période sélectionnée.`;
}
} catch (err) {
if (tbody) {
tbody.innerHTML = `
<tr>
<td colspan="7" class="py-8 text-center text-rose-400">
Erreur de chargement du journal d'appels : ${escapeHtml(err.message)}
</td>
</tr>
`;
}
} finally {
if (spinner) spinner.classList.remove('animate-spin');
}
}
function renderCdrStats(stats) {
const elTotal = document.getElementById('cdr-stat-total');
const elAns = document.getElementById('cdr-stat-answered');
const elRate = document.getElementById('cdr-stat-rate');
const elMiss = document.getElementById('cdr-stat-missed');
const elOut = document.getElementById('cdr-stat-outbound');
const elIn = document.getElementById('cdr-stat-inbound');
const total = stats.total || 0;
const answered = stats.answered || 0;
const missed = stats.missed || 0;
const rate = total > 0 ? Math.round((answered / total) * 100) : 0;
if (elTotal) elTotal.textContent = total;
if (elAns) elAns.textContent = answered;
if (elRate) elRate.textContent = `${rate}% de réponse`;
if (elMiss) elMiss.textContent = missed;
if (elOut) elOut.textContent = stats.outbound || 0;
if (elIn) elIn.textContent = stats.inbound || 0;
}
function renderCdrTable(calls) {
const tbody = document.getElementById('cdr-table-body');
if (!tbody) return;
if (!calls || calls.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="7" class="py-8 text-center text-slate-400">
Aucun appel enregistré pour les filtres sélectionnés.
</td>
</tr>
`;
return;
}
tbody.innerHTML = calls.map(c => {
// Direction badge & icon
let dirBadge = '';
if (c.direction === 'inbound') {
dirBadge = `<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[11px] font-bold bg-teal-500/15 text-teal-300 border border-teal-500/30">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 14l-7 7m0 0l-7-7m7 7V3"/></svg>
<span>Entrant</span>
</span>`;
} else if (c.direction === 'outbound') {
dirBadge = `<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[11px] font-bold bg-cyan-500/15 text-cyan-300 border border-cyan-500/30">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 10l7-7m0 0l7 7m-7-7v18"/></svg>
<span>Sortant</span>
</span>`;
} else if (c.direction === 'service') {
dirBadge = `<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[11px] font-bold bg-amber-500/15 text-amber-300 border border-amber-500/30">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
<span>Service</span>
</span>`;
} else if (c.direction === 'group') {
dirBadge = `<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[11px] font-bold bg-purple-500/15 text-purple-300 border border-purple-500/30">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
<span>Groupe</span>
</span>`;
} else if (c.direction === 'ivr') {
dirBadge = `<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[11px] font-bold bg-pink-500/15 text-pink-300 border border-pink-500/30">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z"/></svg>
<span>SVI</span>
</span>`;
} else {
dirBadge = `<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[11px] font-bold bg-indigo-500/15 text-indigo-300 border border-indigo-500/30">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4"/></svg>
<span>Interne</span>
</span>`;
}
// Status badge
let statusBadge = '';
if (c.disposition === 'ANSWERED') {
statusBadge = `<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-[11px] font-bold bg-emerald-500/15 text-emerald-300 border border-emerald-500/30">
<span class="w-1.5 h-1.5 rounded-full bg-emerald-400"></span>
<span>Décroché</span>
</span>`;
} else if (c.disposition === 'NO ANSWER') {
statusBadge = `<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-[11px] font-bold bg-amber-500/15 text-amber-300 border border-amber-500/30">
<span class="w-1.5 h-1.5 rounded-full bg-amber-400"></span>
<span>Non répondu</span>
</span>`;
} else if (c.disposition === 'BUSY') {
statusBadge = `<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-[11px] font-bold bg-purple-500/15 text-purple-300 border border-purple-500/30">
<span class="w-1.5 h-1.5 rounded-full bg-purple-400"></span>
<span>Occupé</span>
</span>`;
} else {
statusBadge = `<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-[11px] font-bold bg-rose-500/15 text-rose-300 border border-rose-500/30">
<span class="w-1.5 h-1.5 rounded-full bg-rose-400"></span>
<span>Échec</span>
</span>`;
}
// Trunk badge
let trunkBadge = '';
if (c.trunk_name) {
trunkBadge = `<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-xl text-xs font-semibold bg-emerald-950/60 text-emerald-300 border border-emerald-500/40 shadow-sm">
<svg class="w-3.5 h-3.5 text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9"/></svg>
<span>${escapeHtml(c.trunk_name)}</span>
</span>`;
} else {
trunkBadge = `<span class="text-slate-500 font-mono text-xs">—</span>`;
}
// Caller display
const callerNameHtml = c.caller_name ? `<div class="font-bold text-white text-xs">${escapeHtml(c.caller_name)}</div>` : '';
const callerNumHtml = `<div class="font-mono text-slate-300 text-xs">${escapeHtml(c.caller_number || 'Inconnu')}</div>`;
// Callee display
const calleeNameHtml = c.callee_name ? `<div class="font-bold text-white text-xs">${escapeHtml(c.callee_name)}</div>` : '';
const calleeNumHtml = `<div class="font-mono text-slate-300 text-xs">${escapeHtml(c.callee_number || '—')}</div>`;
// Talked vs Total duration
const durationHtml = c.billsec > 0
? `<div><span class="font-bold text-emerald-300 font-mono text-xs">${c.billsec_formatted}</span> <span class="text-[10px] text-slate-500 font-sans">(${c.duration_formatted} tot.)</span></div>`
: `<span class="font-mono text-slate-400 text-xs">${c.duration_formatted}</span>`;
return `
<tr class="hover:bg-anthracite-750/50 transition">
<td class="py-3 px-4 whitespace-nowrap text-slate-300 font-mono text-xs">
<div class="font-semibold text-slate-200">${escapeHtml(c.datetime_formatted)}</div>
<div class="text-[10px] text-slate-500 font-sans">${escapeHtml(c.datetime)}</div>
</td>
<td class="py-3 px-3 whitespace-nowrap">
${dirBadge}
</td>
<td class="py-3 px-4">
${callerNameHtml}
${callerNumHtml}
</td>
<td class="py-3 px-4">
${calleeNameHtml}
${calleeNumHtml}
</td>
<td class="py-3 px-4 whitespace-nowrap">
${trunkBadge}
</td>
<td class="py-3 px-3 whitespace-nowrap">
${durationHtml}
</td>
<td class="py-3 px-3 whitespace-nowrap">
${statusBadge}
</td>
</tr>
`;
}).join('');
}
function exportCdrCsv() {
const search = encodeURIComponent(document.getElementById('cdr-filter-search')?.value || '');
const period = encodeURIComponent(document.getElementById('cdr-filter-period')?.value || 'all');
const direction = encodeURIComponent(document.getElementById('cdr-filter-direction')?.value || 'all');
const disposition = encodeURIComponent(document.getElementById('cdr-filter-disposition')?.value || 'all');
window.location.href = `/api.php?action=export_cdr_csv&search=${search}&period=${period}&direction=${direction}&disposition=${disposition}`;
}
async function confirmClearCdr() {
if (!confirm("Êtes-vous sûr de vouloir purger l'historique complet du journal d'appels Asterisk ? Cette action est irréversible.")) {
return;
}
try {
const res = await apiCall('clear_cdr', {});
showToast(res.message || "Journal d'appels réinitialisé.", 'success');
loadCdr();
} catch (e) {
showToast(e.message || "Erreur lors de la purge du journal.", 'error');
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 795 KiB

+174 -16
View File
@@ -18,7 +18,7 @@ $companyLogo = trim($branding['company_logo'] ?? '');
$hasBranding = !empty($companyName) || !empty($companyLogo);
$appVerData = file_exists(__DIR__ . '/../version.json') ? json_decode((string)file_get_contents(__DIR__ . '/../version.json'), true) : [];
$appVersion = $appVerData['version'] ?? '1.4.5';
$appVersion = $appVerData['version'] ?? '1.5.0';
?>
<!DOCTYPE html>
<html lang="fr" class="h-full">
@@ -26,7 +26,9 @@ $appVersion = $appVerData['version'] ?? '1.4.5';
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="nexus-app-version" content="<?= htmlspecialchars($appVersion) ?>">
<title>NexusPBX — Supervision & Gestion PJSIP sur Debian</title>
<title>NexusPBX — Supervision & Téléphonie IP • Powered by Asterisk</title>
<link rel="icon" type="image/png" href="/assets/favicon.png?v=<?= file_exists(__DIR__ . '/assets/favicon.png') ? filemtime(__DIR__ . '/assets/favicon.png') : 1 ?>">
<link rel="apple-touch-icon" href="/assets/logo.png?v=<?= file_exists(__DIR__ . '/assets/logo.png') ? filemtime(__DIR__ . '/assets/logo.png') : 1 ?>">
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
@@ -157,15 +159,11 @@ $appVersion = $appVerData['version'] ?? '1.4.5';
<!-- Carte de connexion flottante avec effet Glassmorphism transparent et aéré -->
<div class="relative z-10 w-full max-w-md bg-anthracite-900/60 backdrop-blur-2xl border border-white/15 rounded-3xl shadow-2xl shadow-black/50 p-8 sm:p-9 transition-all">
<div class="flex items-center gap-3.5 mb-6">
<div class="w-12 h-12 rounded-xl bg-indigo-500/20 border border-indigo-400/40 flex items-center justify-center text-indigo-300 shadow-inner">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"/>
</svg>
</div>
<div class="flex items-center gap-4 mb-6">
<img src="/assets/logo.png?v=<?= file_exists(__DIR__ . '/assets/logo.png') ? filemtime(__DIR__ . '/assets/logo.png') : 1 ?>" alt="NexusPBX Logo" class="w-20 h-20 sm:w-24 sm:h-24 object-contain shrink-0 filter drop-shadow-xl">
<div>
<h1 class="text-xl font-bold text-white tracking-tight">NexusPBX</h1>
<p class="text-xs text-slate-300">Supervision & Gestion PJSIP • Debian 13</p>
<h1 class="text-2xl sm:text-3xl font-black text-white tracking-tight leading-none mb-1">NexusPBX</h1>
<p class="text-xs text-slate-300">Supervision & Téléphonie IP • Powered by Asterisk</p>
</div>
</div>
@@ -207,11 +205,7 @@ $appVersion = $appVerData['version'] ?? '1.4.5';
<div class="px-4 py-3.5 border-b border-anthracite-700/80 space-y-2.5">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-indigo-500/30 to-purple-600/20 border border-indigo-400/40 flex items-center justify-center text-indigo-300 shadow-lg shadow-indigo-500/10 shrink-0">
<svg class="w-5 h-5 text-indigo-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"/>
</svg>
</div>
<img src="/assets/logo.png?v=<?= file_exists(__DIR__ . '/assets/logo.png') ? filemtime(__DIR__ . '/assets/logo.png') : 1 ?>" alt="NexusPBX Logo" class="w-14 h-14 object-contain shrink-0 filter drop-shadow-md">
<div>
<div class="flex items-center gap-2">
<div class="font-black text-white text-2xl tracking-tight leading-none">
@@ -219,7 +213,7 @@ $appVersion = $appVerData['version'] ?? '1.4.5';
</div>
<span id="app-version-badge" class="px-1.5 py-0.5 text-[10px] font-mono font-bold tracking-wider rounded bg-indigo-500/20 text-indigo-300 border border-indigo-500/30">v<?= htmlspecialchars($appVersion) ?></span>
</div>
<div class="text-[10px] text-slate-400 font-medium tracking-wide mt-0.5">Moteur VoIP & Téléphonie IP</div>
<div class="text-[10px] text-slate-400 font-medium tracking-wide mt-0.5">Powered by Asterisk</div>
</div>
</div>
<button type="button" onclick="toggleMobileSidebar(false)" class="lg:hidden text-slate-400 hover:text-white p-1">✕</button>
@@ -258,6 +252,18 @@ $appVersion = $appVerData['version'] ?? '1.4.5';
<span class="w-2 h-2 rounded-full bg-emerald-300 animate-pulse"></span>
</button>
<!-- Journal d'appels (CDR) -->
<button type="button" data-tab="cdr" onclick="switchTab('cdr')"
class="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">
<span class="flex items-center gap-3">
<svg class="w-5 h-5 text-sky-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<span>Journal d'appels (CDR)</span>
</span>
<span id="nav-badge-cdr" class="px-2 py-0.5 text-xs rounded-full bg-anthracite-800 text-sky-300 border border-anthracite-700 font-bold">CDR</span>
</button>
<div class="pt-3 px-3 py-1.5 text-[11px] font-bold uppercase tracking-wider text-slate-400">Téléphonie IP</div>
<!-- Postes SIP -->
@@ -980,6 +986,158 @@ $appVersion = $appVerData['version'] ?? '1.4.5';
</div>
</section>
<!-- ========================================================= -->
<!-- ONGLET : JOURNAL D'APPELS (CDR) -->
<!-- ========================================================= -->
<section id="tab-cdr" class="tab-panel hidden space-y-4">
<!-- En-tête avec Actions -->
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div>
<div class="flex items-center gap-2">
<h2 class="text-lg font-bold text-white">Journal d'Appels & Historique CDR</h2>
<span class="px-2 py-0.5 rounded-full text-[10px] font-bold bg-sky-500/20 text-sky-300 border border-sky-500/30">Asterisk CDR</span>
</div>
<p class="text-xs text-slate-300">Traçabilité complète des flux téléphoniques entrants, sortants et internes avec identification automatique des correspondants et des Trunks SIP.</p>
</div>
<div class="flex items-center flex-wrap gap-2">
<button type="button" onclick="loadCdr()" id="btn-refresh-cdr"
class="inline-flex items-center gap-1.5 px-3 py-2 rounded-xl text-xs sm:text-sm font-medium bg-anthracite-800 hover:bg-anthracite-750 text-slate-200 border border-anthracite-700 transition" title="Recharger les derniers appels">
<svg id="spinner-refresh-cdr" class="w-4 h-4 text-sky-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
<span>Actualiser</span>
</button>
<button type="button" onclick="exportCdrCsv()"
class="inline-flex items-center gap-1.5 px-3 py-2 rounded-xl text-xs sm:text-sm font-medium bg-emerald-600/20 hover:bg-emerald-600/30 text-emerald-300 border border-emerald-500/40 shadow-sm transition" title="Exporter au format CSV compatible Excel">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
<span>Exporter CSV</span>
</button>
<button type="button" onclick="confirmClearCdr()"
class="inline-flex items-center gap-1.5 px-3 py-2 rounded-xl text-xs sm:text-sm font-medium bg-rose-600/10 hover:bg-rose-600/20 text-rose-300 border border-rose-500/30 transition" title="Purger le journal d'appels">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
<span>Purger</span>
</button>
</div>
</div>
<!-- Cartes KPIs Statistiques -->
<div class="grid grid-cols-2 lg:grid-cols-5 gap-3">
<div class="bg-anthracite-800 border border-anthracite-700 rounded-2xl p-4 shadow-lg">
<div class="text-[11px] font-semibold uppercase text-slate-400">Total Appels</div>
<div id="cdr-stat-total" class="text-2xl font-bold text-white mt-1">0</div>
<div class="text-[11px] text-slate-400 mt-0.5">Sur la période</div>
</div>
<div class="bg-anthracite-800 border border-anthracite-700 rounded-2xl p-4 shadow-lg">
<div class="text-[11px] font-semibold uppercase text-emerald-400">Décrochés</div>
<div id="cdr-stat-answered" class="text-2xl font-bold text-emerald-400 mt-1">0</div>
<div id="cdr-stat-rate" class="text-[11px] text-emerald-300 font-semibold mt-0.5">0% de réponse</div>
</div>
<div class="bg-anthracite-800 border border-anthracite-700 rounded-2xl p-4 shadow-lg">
<div class="text-[11px] font-semibold uppercase text-amber-400">Non Répondus / Manqués</div>
<div id="cdr-stat-missed" class="text-2xl font-bold text-amber-400 mt-1">0</div>
<div class="text-[11px] text-slate-400 mt-0.5">Sans réponse / échecs</div>
</div>
<div class="bg-anthracite-800 border border-anthracite-700 rounded-2xl p-4 shadow-lg">
<div class="text-[11px] font-semibold uppercase text-cyan-400">Appels Sortants</div>
<div id="cdr-stat-outbound" class="text-2xl font-bold text-cyan-400 mt-1">0</div>
<div class="text-[11px] text-slate-400 mt-0.5">Vers opérateurs / Trunks</div>
</div>
<div class="bg-anthracite-800 border border-anthracite-700 rounded-2xl p-4 shadow-lg">
<div class="text-[11px] font-semibold uppercase text-teal-400">Appels Entrants</div>
<div id="cdr-stat-inbound" class="text-2xl font-bold text-teal-400 mt-1">0</div>
<div class="text-[11px] text-slate-400 mt-0.5">Reçus via SDA / Trunks</div>
</div>
</div>
<!-- Barre de Recherche & Filtres Multicritères -->
<div class="bg-anthracite-800 border border-anthracite-700 rounded-2xl p-4 shadow-lg space-y-3">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-3">
<!-- Recherche textuelle -->
<div class="relative lg:col-span-2">
<span class="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none text-slate-400">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
</span>
<input type="text" id="cdr-filter-search" oninput="debounceCdrLoad()" placeholder="Rechercher par appelant, destinataire, trunk..."
class="w-full pl-10 pr-4 py-2 rounded-xl bg-anthracite-950 border border-anthracite-700 text-xs sm:text-sm text-white placeholder-slate-500 focus:outline-none focus:border-sky-500 transition">
</div>
<!-- Filtre Période -->
<div>
<select id="cdr-filter-period" onchange="loadCdr()"
class="w-full px-3 py-2 rounded-xl bg-anthracite-950 border border-anthracite-700 text-xs text-white focus:outline-none focus:border-sky-500 transition">
<option value="all">Toute la période</option>
<option value="today" selected>Aujourd'hui</option>
<option value="yesterday">Hier</option>
<option value="week">7 derniers jours</option>
<option value="month">30 derniers jours</option>
</select>
</div>
<!-- Filtre Direction -->
<div>
<select id="cdr-filter-direction" onchange="loadCdr()"
class="w-full px-3 py-2 rounded-xl bg-anthracite-950 border border-anthracite-700 text-xs text-white focus:outline-none focus:border-sky-500 transition">
<option value="all">Toutes les directions</option>
<option value="inbound">Entrants (Trunk → Poste)</option>
<option value="outbound">Sortants (Poste → Trunk)</option>
<option value="internal">Internes (Poste à Poste)</option>
<option value="service">Services (*98, SVI, etc.)</option>
</select>
</div>
<!-- Filtre Statut / Disposition -->
<div>
<select id="cdr-filter-disposition" onchange="loadCdr()"
class="w-full px-3 py-2 rounded-xl bg-anthracite-950 border border-anthracite-700 text-xs text-white focus:outline-none focus:border-sky-500 transition">
<option value="all">Tous les statuts</option>
<option value="answered">Décrochés (Répondus)</option>
<option value="no_answer">Non répondus (Manqués)</option>
<option value="busy">Occupés</option>
<option value="failed">Échecs</option>
</select>
</div>
</div>
<!-- Ligne d'info & Limite d'affichage -->
<div class="flex items-center justify-between pt-2 border-t border-anthracite-700/60 text-xs text-slate-400">
<div id="cdr-status-info">Chargement de l'historique...</div>
<div class="flex items-center gap-2">
<span>Afficher :</span>
<select id="cdr-filter-limit" onchange="loadCdr()"
class="px-2 py-1 rounded-lg bg-anthracite-950 border border-anthracite-700 text-xs text-white focus:outline-none">
<option value="25">25</option>
<option value="50">50</option>
<option value="100" selected>100</option>
<option value="250">250</option>
<option value="500">500</option>
</select>
</div>
</div>
</div>
<!-- Tableau du Journal d'Appels -->
<div class="bg-anthracite-800 border border-anthracite-700 rounded-2xl overflow-hidden shadow-lg">
<div class="overflow-x-auto">
<table class="w-full text-left border-collapse">
<thead>
<tr class="bg-anthracite-950/80 text-[11px] font-semibold uppercase text-slate-400 border-b border-anthracite-700">
<th class="py-3 px-4">Date & Heure</th>
<th class="py-3 px-3">Direction</th>
<th class="py-3 px-4">Appelant (Qui a appelé)</th>
<th class="py-3 px-4">Destinataire (Qui a été appelé)</th>
<th class="py-3 px-4">Trunk Utilisé</th>
<th class="py-3 px-3">Durée Parlée</th>
<th class="py-3 px-3">Statut</th>
</tr>
</thead>
<tbody id="cdr-table-body" class="divide-y divide-anthracite-700/60 text-xs">
<tr>
<td colspan="7" class="py-8 text-center text-slate-400">Chargement du journal d'appels...</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
<!-- ========================================================= -->
<!-- ONGLET 1 : EXTENSIONS SIP -->
<!-- ========================================================= -->
+55 -10
View File
@@ -20,11 +20,11 @@ echo "-> Installation des dépendances système indispensables (ACL, ffmpeg, rsy
which ffmpeg >/dev/null 2>&1 && which rsync >/dev/null 2>&1 && which setfacl >/dev/null 2>&1 && which sshpass >/dev/null 2>&1 || (apt-get update -y && apt-get install -y --no-install-recommends acl ffmpeg rsync sox sshpass)
echo "-> Création des dossiers et des fichiers de journalisation..."
mkdir -p /etc/fail2ban/filter.d /etc/asterisk /var/log/asterisk /var/log/nexus-pbx /var/www/nexus-pbx/storage/backups
mkdir -p /etc/fail2ban/filter.d /etc/asterisk /var/log/asterisk /var/log/asterisk/cdr-csv /var/log/nexus-pbx /var/www/nexus-pbx/storage/backups
touch /var/log/asterisk/messages /var/log/asterisk/security.log /var/log/nexus_security.log
chown asterisk:asterisk /var/log/asterisk/messages /var/log/asterisk/security.log 2>/dev/null || true
chmod 0664 /var/log/asterisk/messages /var/log/asterisk/security.log 2>/dev/null || true
touch /var/log/asterisk/messages /var/log/asterisk/security.log /var/log/asterisk/cdr-csv/Master.csv /var/log/nexus_security.log
chown asterisk:asterisk /var/log/asterisk/messages /var/log/asterisk/security.log /var/log/asterisk/cdr-csv/Master.csv 2>/dev/null || true
chmod 0664 /var/log/asterisk/messages /var/log/asterisk/security.log /var/log/asterisk/cdr-csv/Master.csv 2>/dev/null || true
chown www-data:asterisk /var/log/nexus_security.log 2>/dev/null || true
chmod 0664 /var/log/nexus_security.log 2>/dev/null || true
@@ -57,7 +57,9 @@ echo "-> Droits applicatifs et ACL POSIX (/etc/asterisk et /var/www/nexus-pbx)..
usermod -aG asterisk www-data 2>/dev/null || true
usermod -aG www-data asterisk 2>/dev/null || true
mkdir -p /var/www/nexus-pbx/public/avatars /var/www/nexus-pbx/storage/sounds /var/www/nexus-pbx/storage/backups /var/www/nexus-pbx/storage/system_backups
mkdir -p /var/www/nexus-pbx/public/avatars /var/www/nexus-pbx/storage/sounds /var/www/nexus-pbx/storage/backups /var/www/nexus-pbx/storage/system_backups /etc/letsencrypt /var/log/letsencrypt /var/lib/letsencrypt
chgrp -R asterisk /etc/letsencrypt 2>/dev/null || true
chmod -R g+rX /etc/letsencrypt 2>/dev/null || true
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 /var/www/nexus-pbx/public/avatars 2>/dev/null || true
@@ -65,20 +67,63 @@ touch /etc/asterisk/pjsip.conf \
/etc/asterisk/pjsip.custom.conf \
/etc/asterisk/extensions.conf \
/etc/asterisk/extensions.custom.conf \
/etc/asterisk/musiconhold.conf \
/etc/asterisk/musiconhold.custom.conf \
/etc/asterisk/voicemail.conf \
/etc/asterisk/voicemail.custom.conf \
/etc/asterisk/modules.conf \
/etc/asterisk/rtp.conf \
/etc/asterisk/features.conf 2>/dev/null || true
mkdir -p /var/lib/asterisk/sounds/custom /var/lib/asterisk/moh /var/spool/asterisk/voicemail 2>/dev/null || true
# Désactivation des modules ODBC/IMAP incompatibles sur Debian (évite conflit bloquant app_voicemail)
if ! grep -q 'noload => app_voicemail_odbc.so' /etc/asterisk/modules.conf 2>/dev/null; then
if grep -q '\[modules\]' /etc/asterisk/modules.conf 2>/dev/null; then
sed -i '/\[modules\]/a noload => app_voicemail_odbc.so\nnoload => app_voicemail_imap.so' /etc/asterisk/modules.conf 2>/dev/null || true
else
printf '[modules]\nautoload=yes\nnoload => app_voicemail_odbc.so\nnoload => app_voicemail_imap.so\n' >> /etc/asterisk/modules.conf
fi
fi
# Inclusions Asterisk et configuration messagerie vocale
if ! grep -q 'pjsip.custom.conf' /etc/asterisk/pjsip.conf 2>/dev/null; then
printf '\n#tryinclude "pjsip.custom.conf"\n' >> /etc/asterisk/pjsip.conf
fi
if ! grep -q 'extensions.custom.conf' /etc/asterisk/extensions.conf 2>/dev/null; then
printf '\n#tryinclude "extensions.custom.conf"\n' >> /etc/asterisk/extensions.conf
fi
if ! grep -q 'musiconhold.custom.conf' /etc/asterisk/musiconhold.conf 2>/dev/null; then
sed -i 's/^\[default\]/;[default]/' /etc/asterisk/musiconhold.conf 2>/dev/null || true
sed -i 's/^mode=files/;mode=files/' /etc/asterisk/musiconhold.conf 2>/dev/null || true
sed -i 's/^directory=moh/;directory=moh/' /etc/asterisk/musiconhold.conf 2>/dev/null || true
printf '\n#tryinclude "/etc/asterisk/musiconhold.custom.conf"\n' >> /etc/asterisk/musiconhold.conf
fi
if ! grep -q 'voicemail.custom.conf' /etc/asterisk/voicemail.conf 2>/dev/null; then
sed -i 's/^1234 => 4242/;1234 => 4242/' /etc/asterisk/voicemail.conf 2>/dev/null || true
sed -i 's/^;\s*passwordlocation\s*=\s*spooldir/passwordlocation=spooldir/' /etc/asterisk/voicemail.conf 2>/dev/null || true
sed -i 's|^;\s*externpassnotify\s*=.*|externpassnotify=/usr/bin/php /var/www/nexus-pbx/scripts/voicemail_pass_sync.php|' /etc/asterisk/voicemail.conf 2>/dev/null || true
printf '\n#tryinclude "voicemail.custom.conf"\n' >> /etc/asterisk/voicemail.conf
fi
mkdir -p /var/lib/asterisk/sounds/custom /var/lib/asterisk/moh /var/spool/asterisk/voicemail/default 2>/dev/null || true
for lang in fr en es; do
if [ -d "/usr/share/asterisk/sounds/$lang" ] && [ ! -e "/var/lib/asterisk/sounds/$lang" ]; then
ln -sf "/usr/share/asterisk/sounds/$lang" "/var/lib/asterisk/sounds/$lang" 2>/dev/null || true
fi
if [ -d "/var/lib/asterisk/sounds/$lang" ] && [ ! -e "/var/lib/asterisk/sounds/$lang/custom" ]; then
ln -sf /var/lib/asterisk/sounds/custom "/var/lib/asterisk/sounds/$lang/custom" 2>/dev/null || true
fi
done
if [ ! -e "/usr/share/asterisk/sounds/custom" ]; then
ln -sf /var/lib/asterisk/sounds/custom /usr/share/asterisk/sounds/custom 2>/dev/null || true
fi
chown -R asterisk:asterisk /etc/asterisk /var/lib/asterisk /var/spool/asterisk /var/log/asterisk 2>/dev/null || true
chmod -R 775 /etc/asterisk /var/lib/asterisk/sounds /var/lib/asterisk/moh /var/spool/asterisk 2>/dev/null || true
chmod 2775 /etc/asterisk /var/lib/asterisk/sounds /var/lib/asterisk/sounds/custom /var/lib/asterisk/moh /var/spool/asterisk 2>/dev/null || true
chmod -R 775 /etc/asterisk /var/lib/asterisk/sounds /var/lib/asterisk/moh /var/spool/asterisk /var/log/asterisk 2>/dev/null || true
chmod 2775 /etc/asterisk /var/lib/asterisk/sounds /var/lib/asterisk/sounds/custom /var/lib/asterisk/moh /var/spool/asterisk /var/spool/asterisk/voicemail /var/log/asterisk /var/log/asterisk/cdr-csv 2>/dev/null || true
chmod 2750 /etc/asterisk/keys 2>/dev/null || true
chmod 0664 /etc/asterisk/*.conf /etc/asterisk/*.custom.conf 2>/dev/null || true
setfacl -R -m u:www-data:rwx,g:asterisk:rwx /etc/asterisk /var/lib/asterisk/sounds /var/lib/asterisk/moh /var/spool/asterisk 2>/dev/null || true
setfacl -R -d -m u:www-data:rwx,g:asterisk:rwx /etc/asterisk /var/lib/asterisk/sounds /var/lib/asterisk/moh /var/spool/asterisk 2>/dev/null || true
setfacl -R -m u:www-data:rwx,g:asterisk:rwx /etc/asterisk /var/lib/asterisk/sounds /var/lib/asterisk/moh /var/spool/asterisk /var/log/asterisk 2>/dev/null || true
setfacl -R -d -m u:www-data:rwx,g:asterisk:rwx /etc/asterisk /var/lib/asterisk/sounds /var/lib/asterisk/moh /var/spool/asterisk /var/log/asterisk 2>/dev/null || true
chown root:asterisk /etc/fail2ban/jail.local /etc/nginx/sites-available/nexus-pbx 2>/dev/null || true
chmod 0664 /etc/fail2ban/jail.local /etc/nginx/sites-available/nexus-pbx 2>/dev/null || true
+27 -2
View File
@@ -96,6 +96,9 @@ echo -e "${C_GRAY}[2/6] Déploiement des nouveaux fichiers de code...${C_RESET}"
git reset --hard FETCH_HEAD 2>/dev/null || git reset --hard "${GIT_REMOTE}/${GIT_BRANCH}"
chown -R www-data:asterisk "$APP_DIR" 2>/dev/null || true
chmod 2775 "$APP_DIR/storage" "$APP_DIR/storage/backups" "$APP_DIR/storage/sounds" 2>/dev/null || true
mkdir -p /etc/letsencrypt /var/log/letsencrypt /var/lib/letsencrypt 2>/dev/null || true
chgrp -R asterisk /etc/letsencrypt 2>/dev/null || true
chmod -R g+rX /etc/letsencrypt 2>/dev/null || true
chmod +x "$APP_DIR/scripts/update.sh" "$APP_DIR/scripts/nexus-firewall" "$APP_DIR/scripts/nexus-banner.sh" 2>/dev/null || true
# Liens symboliques CLI (non-bloquants si exécuté sous sandbox PHP ProtectSystem)
@@ -130,8 +133,30 @@ $gen->writeAllConfigs();
# 5. Fix permissions & ACLs
echo -e "${C_GRAY}[5/6] Application des permissions et ACLs système...${C_RESET}"
setfacl -R -m u:www-data:rwx,g:asterisk:rwx /etc/asterisk /var/lib/asterisk/sounds /var/lib/asterisk/moh 2>/dev/null || true
setfacl -R -d -m u:www-data:rwx,g:asterisk:rwx /etc/asterisk /var/lib/asterisk/sounds /var/lib/asterisk/moh 2>/dev/null || true
php -r '
require_once "/var/www/nexus-pbx/src/Database.php";
require_once "/var/www/nexus-pbx/src/SystemChecker.php";
App\SystemChecker::ensureAsteriskIncludes();
' 2>/dev/null || true
mkdir -p /var/spool/asterisk/voicemail/default /var/lib/asterisk/sounds/custom /var/lib/asterisk/moh 2>/dev/null || true
for lang in fr en es; do
if [ -d "/usr/share/asterisk/sounds/$lang" ] && [ ! -e "/var/lib/asterisk/sounds/$lang" ]; then
ln -sf "/usr/share/asterisk/sounds/$lang" "/var/lib/asterisk/sounds/$lang" 2>/dev/null || true
fi
if [ -d "/var/lib/asterisk/sounds/$lang" ] && [ ! -e "/var/lib/asterisk/sounds/$lang/custom" ]; then
ln -sf /var/lib/asterisk/sounds/custom "/var/lib/asterisk/sounds/$lang/custom" 2>/dev/null || true
fi
done
if [ ! -e "/usr/share/asterisk/sounds/custom" ]; then
ln -sf /var/lib/asterisk/sounds/custom /usr/share/asterisk/sounds/custom 2>/dev/null || true
fi
chown -R asterisk:asterisk /var/spool/asterisk /var/lib/asterisk/sounds /var/lib/asterisk/moh /var/log/asterisk 2>/dev/null || true
chmod 2775 /var/spool/asterisk /var/spool/asterisk/voicemail /var/lib/asterisk/sounds /var/lib/asterisk/moh /var/log/asterisk /var/log/asterisk/cdr-csv 2>/dev/null || true
mkdir -p /var/log/asterisk/cdr-csv 2>/dev/null || true
touch /var/log/asterisk/cdr-csv/Master.csv 2>/dev/null || true
chmod 0664 /var/log/asterisk/cdr-csv/Master.csv 2>/dev/null || true
setfacl -R -m u:www-data:rwx,g:asterisk:rwx /etc/asterisk /var/lib/asterisk/sounds /var/lib/asterisk/moh /var/spool/asterisk /var/log/asterisk 2>/dev/null || true
setfacl -R -d -m u:www-data:rwx,g:asterisk:rwx /etc/asterisk /var/lib/asterisk/sounds /var/lib/asterisk/moh /var/spool/asterisk /var/log/asterisk 2>/dev/null || true
chmod 0664 /etc/asterisk/*.conf /etc/asterisk/*.custom.conf 2>/dev/null || true
# 6. Rechargement des services
+478
View File
@@ -0,0 +1,478 @@
<?php
declare(strict_types=1);
namespace App;
use PDO;
use Throwable;
class CdrManager
{
public const CDR_FILE = '/var/log/asterisk/cdr-csv/Master.csv';
/**
* Récupère la liste analysée et enrichie des derniers appels Asterisk.
*
* @param PDO $db
* @param array $filters [limit => 100, direction => 'all', disposition => 'all', period => 'all', search => '']
* @return array [calls => array, stats => array, total_records => int]
*/
public static function getCalls(PDO $db, array $filters = []): array
{
$limit = max(10, min(1000, (int)($filters['limit'] ?? 100)));
$direction = trim((string)($filters['direction'] ?? 'all'));
$disposition = trim((string)($filters['disposition'] ?? 'all'));
$period = trim((string)($filters['period'] ?? 'all'));
$search = strtolower(trim((string)($filters['search'] ?? '')));
if (!file_exists(self::CDR_FILE) || !is_readable(self::CDR_FILE)) {
return [
'calls' => [],
'stats' => self::emptyStats(),
'total_records' => 0,
];
}
// 1. Dictionnaires en mémoire pour résolution rapide
$trunks = $db->query('SELECT id, name, host, username FROM trunks')->fetchAll(PDO::FETCH_ASSOC) ?: [];
$trunkMap = [];
foreach ($trunks as $t) {
$info = ['id' => (int)$t['id'], 'name' => (string)$t['name']];
$name = strtolower(trim((string)$t['name']));
$user = strtolower(trim((string)($t['username'] ?? '')));
$slug = self::slugify($t['name']);
$trunkMap[$name] = $info;
$trunkMap["trunk-{$name}"] = $info;
$trunkMap[$slug] = $info;
$trunkMap["trunk-{$slug}"] = $info;
if ($user !== '') {
$trunkMap[$user] = $info;
$trunkMap["trunk-{$user}"] = $info;
}
}
$extensions = $db->query('SELECT extension, name FROM extensions')->fetchAll(PDO::FETCH_ASSOC) ?: [];
$extMap = [];
foreach ($extensions as $e) {
$extMap[(string)$e['extension']] = (string)$e['name'];
}
$groups = $db->query('SELECT extension, name FROM groups')->fetchAll(PDO::FETCH_ASSOC) ?: [];
$groupMap = [];
foreach ($groups as $g) {
$groupMap[(string)$g['extension']] = (string)$g['name'];
}
$ivrs = $db->query('SELECT id, name FROM ivrs')->fetchAll(PDO::FETCH_ASSOC) ?: [];
$ivrMap = [];
foreach ($ivrs as $i) {
$ivrMap[(int)$i['id']] = (string)$i['name'];
}
$contactMap = [];
try {
$contacts = $db->query('SELECT display_name, internal_number, mobile_number, other_number FROM phonebook_contacts')->fetchAll(PDO::FETCH_ASSOC) ?: [];
foreach ($contacts as $c) {
$cName = trim((string)$c['display_name']);
if ($cName === '') continue;
if (!empty($c['internal_number'])) $contactMap[trim((string)$c['internal_number'])] = $cName;
if (!empty($c['mobile_number'])) $contactMap[trim((string)$c['mobile_number'])] = $cName;
if (!empty($c['other_number'])) $contactMap[trim((string)$c['other_number'])] = $cName;
}
} catch (Throwable) {}
// 2. Lecture des dernières lignes du fichier CSV (inversées pour avoir les plus récents en premier)
$rawLines = self::tailFile(self::CDR_FILE, 2000);
$rawLines = array_reverse($rawLines);
$now = time();
$calls = [];
$stats = self::emptyStats();
$totalParsed = 0;
foreach ($rawLines as $line) {
$row = str_getcsv($line);
if (count($row) < 15) continue;
$accountcode = trim((string)($row[0] ?? ''));
$src = trim((string)($row[1] ?? ''));
$dst = trim((string)($row[2] ?? ''));
$dcontext = trim((string)($row[3] ?? ''));
$clid = trim((string)($row[4] ?? ''));
$channel = trim((string)($row[5] ?? ''));
$dstchannel = trim((string)($row[6] ?? ''));
$lastapp = trim((string)($row[7] ?? ''));
$lastdata = trim((string)($row[8] ?? ''));
$startTime = trim((string)($row[9] ?? ''));
$answerTime = trim((string)($row[10] ?? ''));
$endTime = trim((string)($row[11] ?? ''));
$duration = max(0, (int)($row[12] ?? 0));
$billsec = max(0, (int)($row[13] ?? 0));
$dispRaw = strtoupper(trim((string)($row[14] ?? 'NO ANSWER')));
$uniqueid = trim((string)($row[16] ?? ''));
$startTs = strtotime($startTime) ?: 0;
// Filtre par période
if ($period !== 'all' && $startTs > 0) {
if ($period === 'today') {
if (date('Y-m-d', $startTs) !== date('Y-m-d', $now)) continue;
} elseif ($period === 'yesterday') {
if (date('Y-m-d', $startTs) !== date('Y-m-d', $now - 86400)) continue;
} elseif ($period === 'week') {
if ($startTs < ($now - 7 * 86400)) continue;
} elseif ($period === 'month') {
if ($startTs < ($now - 30 * 86400)) continue;
}
}
// Identification endpoints & direction
$srcEp = self::extractEndpoint($channel);
$dstEp = self::extractEndpoint($dstchannel);
$dialTrunk = self::extractTrunkFromDialData($lastdata);
$dir = 'internal';
$dirLabel = 'Interne';
$trunkName = null;
$trunkId = null;
$candidateOut = strtolower($dialTrunk ?: $dstEp);
if ($candidateOut !== '' && isset($trunkMap[$candidateOut])) {
$dir = 'outbound';
$dirLabel = 'Sortant';
$trunkName = $trunkMap[$candidateOut]['name'];
$trunkId = $trunkMap[$candidateOut]['id'];
} elseif (isset($trunkMap[strtolower($srcEp)])) {
$dir = 'inbound';
$dirLabel = 'Entrant';
$trunkName = $trunkMap[strtolower($srcEp)]['name'];
$trunkId = $trunkMap[strtolower($srcEp)]['id'];
} elseif (in_array($dcontext, ['from-external', 'from-pstn', 'from-trunk', 'from-pstn-toheader'], true)) {
$dir = 'inbound';
$dirLabel = 'Entrant';
$trunkName = 'Trunk Externe';
} elseif (str_starts_with($dst, '*') || in_array($lastapp, ['VoiceMailMain', 'Echo', 'WaitExten', 'Playback'], true)) {
$dir = 'service';
$dirLabel = 'Service';
} elseif (isset($groupMap[$dst])) {
$dir = 'group';
$dirLabel = 'Groupe';
} elseif (isset($ivrMap[(int)ltrim($dst, '*80')]) || str_starts_with($dst, '*8')) {
$dir = 'ivr';
$dirLabel = 'SVI / IVR';
}
// Identification de l'Appelant et du Destinataire
$callerNum = $src;
$callerName = '';
$calleeNum = $dst;
$calleeName = '';
// Appel sortant : le poste physique réel appelant se trouve dans channel
if ($dir === 'outbound' && isset($extMap[$srcEp])) {
$callerNum = $srcEp;
$callerName = $extMap[$srcEp];
} elseif (isset($extMap[$src])) {
$callerName = $extMap[$src];
} elseif (isset($contactMap[$src])) {
$callerName = $contactMap[$src];
} else {
$callerName = self::extractNameFromClid($clid);
}
// Destinataire
if (isset($extMap[$dst])) {
$calleeName = $extMap[$dst];
} elseif (isset($groupMap[$dst])) {
$calleeName = "Groupe : {$groupMap[$dst]}";
} elseif (isset($contactMap[$dst])) {
$calleeName = $contactMap[$dst];
} elseif ($dst === '*97' || $dst === '*98' || $lastapp === 'VoiceMailMain') {
$calleeName = 'Messagerie Vocale';
} elseif ($dst === '*43' || $lastapp === 'Echo') {
$calleeName = 'Test Écho Audio';
} elseif (str_starts_with($dst, '*80') || str_starts_with($dst, '*8')) {
$ivrId = (int)ltrim(substr($dst, 2), '0');
$calleeName = isset($ivrMap[$ivrId]) ? "SVI : {$ivrMap[$ivrId]}" : 'Serveur Vocal SVI';
}
// Statistiques globales de la période filtrée
$totalParsed++;
$stats['total']++;
if ($dispRaw === 'ANSWERED') {
$stats['answered']++;
} else {
$stats['missed']++;
}
if ($dir === 'inbound') $stats['inbound']++;
if ($dir === 'outbound') $stats['outbound']++;
if ($dir === 'internal') $stats['internal']++;
// Filtre par direction
if ($direction !== 'all' && $dir !== $direction) {
continue;
}
// Filtre par statut (disposition)
if ($disposition !== 'all') {
if ($disposition === 'answered' && $dispRaw !== 'ANSWERED') continue;
if ($disposition === 'no_answer' && $dispRaw !== 'NO ANSWER') continue;
if ($disposition === 'busy' && $dispRaw !== 'BUSY') continue;
if ($disposition === 'failed' && $dispRaw !== 'FAILED') continue;
}
// Filtre de recherche textuelle
if ($search !== '') {
$haystack = strtolower("{$callerNum} {$callerName} {$calleeNum} {$calleeName} {$trunkName} {$lastapp} {$dcontext}");
if (!str_contains($haystack, $search)) {
continue;
}
}
// Formatage statut
$statusLabel = match ($dispRaw) {
'ANSWERED' => 'Décroché',
'NO ANSWER' => 'Non répondu',
'BUSY' => 'Occupé',
'FAILED' => 'Échec',
default => $dispRaw,
};
$statusBadge = match ($dispRaw) {
'ANSWERED' => 'emerald',
'NO ANSWER' => 'amber',
'BUSY' => 'purple',
'FAILED' => 'rose',
default => 'slate',
};
$calls[] = [
'id' => $uniqueid ?: (string)$totalParsed,
'datetime' => $startTime,
'datetime_formatted' => self::formatDate($startTime),
'direction' => $dir,
'direction_label' => $dirLabel,
'caller_number' => $callerNum,
'caller_name' => $callerName,
'callee_number' => $calleeNum,
'callee_name' => $calleeName,
'trunk_name' => $trunkName,
'trunk_id' => $trunkId,
'disposition' => $dispRaw,
'status_label' => $statusLabel,
'status_badge' => $statusBadge,
'duration' => $duration,
'duration_formatted' => self::formatSeconds($duration),
'billsec' => $billsec,
'billsec_formatted' => self::formatSeconds($billsec),
'app' => $lastapp,
'channel' => $channel,
'dstchannel' => $dstchannel,
];
if (count($calls) >= $limit) {
break;
}
}
return [
'calls' => $calls,
'stats' => $stats,
'total_records' => $totalParsed,
];
}
/**
* Génère un export CSV UTF-8 prêt pour Excel / LibreOffice.
*/
public static function exportCsv(PDO $db, array $filters = []): string
{
$filters['limit'] = 5000;
$data = self::getCalls($db, $filters);
$calls = $data['calls'] ?? [];
$output = fopen('php://temp', 'r+');
// BOM UTF-8 pour Excel
fwrite($output, "\xEF\xBB\xBF");
// En-têtes CSV
fputcsv($output, [
'Date & Heure',
'Direction',
'Numéro Appelant',
'Nom Appelant',
'Numéro Destinataire',
'Nom Destinataire',
'Trunk Utilisé',
'Statut',
'Durée Totale (s)',
'Durée Parlée (s)',
'Durée Formatée',
'Application Asterisk',
], ';');
foreach ($calls as $c) {
fputcsv($output, [
$c['datetime'] ?? '',
$c['direction_label'] ?? '',
$c['caller_number'] ?? '',
$c['caller_name'] ?? '',
$c['callee_number'] ?? '',
$c['callee_name'] ?? '',
$c['trunk_name'] ?: 'Aucun (Direct / Interne)',
$c['status_label'] ?? '',
$c['duration'] ?? 0,
$c['billsec'] ?? 0,
$c['billsec_formatted'] ?? '00:00',
$c['app'] ?? '',
], ';');
}
rewind($output);
$csv = stream_get_contents($output);
fclose($output);
return (string)$csv;
}
/**
* Purge ou archive le fichier Master.csv
*/
public static function clearCdr(): bool
{
if (file_exists(self::CDR_FILE) && is_writable(self::CDR_FILE)) {
return (file_put_contents(self::CDR_FILE, '') !== false);
}
return false;
}
// --- Fonctions auxiliaires ---
private static function emptyStats(): array
{
return [
'total' => 0,
'answered' => 0,
'missed' => 0,
'inbound' => 0,
'outbound' => 0,
'internal' => 0,
];
}
public static function extractEndpoint(string $channel): string
{
$channel = trim($channel);
if (preg_match('#^(?:PJSIP|SIP)/([a-zA-Z0-9_\-]+)-[0-9a-fA-F]+#', $channel, $m)) {
return $m[1];
}
if (preg_match('#^(?:PJSIP|SIP)/([a-zA-Z0-9_\-]+)#', $channel, $m)) {
return $m[1];
}
return '';
}
public static function extractTrunkFromDialData(string $lastdata): string
{
if (preg_match('#@([a-zA-Z0-9_\-]+)#', $lastdata, $m)) {
return $m[1];
}
return '';
}
public static function extractNameFromClid(string $clid): string
{
if (preg_match('/"([^"]+)"/', $clid, $m)) {
$name = trim($m[1]);
// Nettoyage de préfixes comme [Sytelis] ou guillemets résiduels
return trim(str_replace(['"', "'"], '', $name));
}
return '';
}
public static function formatSeconds(int $seconds): string
{
$m = floor($seconds / 60);
$s = $seconds % 60;
if ($m >= 60) {
$h = floor($m / 60);
$m = $m % 60;
return sprintf('%02d:%02d:%02d', $h, $m, $s);
}
return sprintf('%02d:%02d', $m, $s);
}
public static function formatDate(string $datetime): string
{
$ts = strtotime($datetime);
if (!$ts) return $datetime;
$today = date('Y-m-d');
$yesterday = date('Y-m-d', time() - 86400);
$day = date('Y-m-d', $ts);
if ($day === $today) {
return 'Aujourd\'hui à ' . date('H:i:s', $ts);
} elseif ($day === $yesterday) {
return 'Hier à ' . date('H:i:s', $ts);
}
return date('d/m/Y H:i', $ts);
}
private static 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';
}
/**
* Lit efficacement les N dernières lignes d'un fichier sans saturer la RAM.
*/
public static function tailFile(string $filepath, int $maxLines = 1000): array
{
if (!file_exists($filepath) || !is_readable($filepath)) {
return [];
}
$filesize = filesize($filepath);
if ($filesize <= 0) return [];
// Fichiers < 5 Mo : lecture directe optimisée
if ($filesize < 5 * 1024 * 1024) {
$lines = @file($filepath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
return array_slice($lines, -$maxLines);
}
// Fichiers volumineux : parcours inverse par curseur fseek
$fp = fopen($filepath, 'rb');
if (!$fp) return [];
$pos = -1;
$lines = [];
$currentLine = '';
while (fseek($fp, $pos, SEEK_END) !== -1 && count($lines) < $maxLines) {
$char = fgetc($fp);
if ($char === "\n") {
if ($currentLine !== '') {
$lines[] = strrev($currentLine);
$currentLine = '';
}
} else {
$currentLine .= $char;
}
$pos--;
}
if ($currentLine !== '' && count($lines) < $maxLines) {
$lines[] = strrev($currentLine);
}
fclose($fp);
return array_reverse($lines);
}
}
+5 -3
View File
@@ -265,8 +265,9 @@ class CertificateManager
$stagingFlag = $staging ? '--test-cert' : '';
// Exécution de Certbot webroot non-interactive (Clé RSA 2048 universellement compatible téléphones IP Yealink/Fanvil/Grandstream)
$cmd = "certbot certonly --webroot -w {$escWebroot} -d {$escDomain} --non-interactive --agree-tos -m {$escEmail} --keep-until-expiring --key-type rsa --rsa-key-size 2048 {$stagingFlag} 2>&1";
// Exécution de Certbot webroot non-interactive avec sudo (Clé RSA 2048 universellement compatible téléphones IP Yealink/Fanvil/Grandstream)
$hookCmd = escapeshellarg("chgrp -R asterisk /etc/letsencrypt 2>/dev/null; chmod -R g+rX /etc/letsencrypt 2>/dev/null || true");
$cmd = "sudo /usr/bin/certbot certonly --webroot -w {$escWebroot} -d {$escDomain} --non-interactive --agree-tos -m {$escEmail} --keep-until-expiring --key-type rsa --rsa-key-size 2048 --post-hook {$hookCmd} {$stagingFlag} 2>&1";
$output = [];
$exitCode = 0;
@@ -444,7 +445,8 @@ class CertificateManager
$domain = $cert['primary_domain'];
$escDomain = escapeshellarg($domain);
$cmd = "certbot renew --cert-name {$escDomain} --force-renewal 2>&1";
$hookCmd = escapeshellarg("chgrp -R asterisk /etc/letsencrypt 2>/dev/null; chmod -R g+rX /etc/letsencrypt 2>/dev/null || true");
$cmd = "sudo /usr/bin/certbot renew --cert-name {$escDomain} --force-renewal --post-hook {$hookCmd} 2>&1";
$output = [];
$exitCode = 0;
exec($cmd, $output, $exitCode);
+25 -15
View File
@@ -156,10 +156,15 @@ class ConfigGenerator
$lines[] = "symmetric_transport = yes";
$lines[] = "cert_file = {$certPath}";
$lines[] = "priv_key_file = {$keyPath}";
if ($caPath !== '') {
// En Asterisk PJSIP, ca_list_file ne doit être renseigné QUE si la vérification
// des certificats clients (mTLS) est requise. Si verify_client=no, renseigner ca_list_file
// force OpenSSL à envoyer une requête CertificateRequest au téléphone, ce qui fait échouer
// le handshake TLS sur la plupart des postes IP (Yealink, Fanvil, Grandstream...).
if ($caPath !== '' && $verifyClient === 'yes') {
$lines[] = "ca_list_file = {$caPath}";
}
$lines[] = "method = tlsv1_2";
// method = sslv23 permet la négociation automatique TLSv1.2 et TLSv1.3 selon le client
$lines[] = "method = sslv23";
$lines[] = "verify_client = {$verifyClient}";
$lines[] = "verify_server = no";
foreach ($localNetworks as $net) {
@@ -256,6 +261,9 @@ class ConfigGenerator
$lines[] = "type = aor";
$lines[] = "max_contacts = {$maxContacts}";
$lines[] = "remove_existing = yes";
$lines[] = "default_expiration = 120";
$lines[] = "maximum_expiration = 300";
$lines[] = "minimum_expiration = 60";
$lines[] = "qualify_frequency = 30";
if ($hasVm) {
$lines[] = "mailboxes = {$num}@default";
@@ -571,10 +579,6 @@ class ConfigGenerator
$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()";
@@ -986,9 +990,10 @@ class ConfigGenerator
$lines[] = "";
}
// Interphonie directe 1-à-1 (*80 + numéro de poste)
// Interphonie directe 1-à-1 (*80 + numéro de poste, ex: *80101 pour le poste 101)
// Pattern _*80XX. pour ne cibler que les numéros de postes (au moins 2 chiffres) et ne pas chevaucher *801
$lines[] = "; --- Interphonie directe 1-à-1 (*80 + numéro de poste) ---";
$lines[] = "exten => _*80.,1,NoOp(Interphonie directe vers poste \${EXTEN:3})";
$lines[] = "exten => _*80XX.,1,NoOp(Interphonie directe vers poste \${EXTEN:3})";
$lines[] = " same => n,Set(CHANNEL(language)={$sysLang})";
$lines[] = " same => n,Answer()";
$lines[] = " same => n,Playback(beep)";
@@ -997,13 +1002,14 @@ class ConfigGenerator
$lines[] = "";
}
// Accès direct aux IVR depuis l'interne via *8 + id
// Accès direct aux IVR depuis l'interne via *8 + id (*801, *802...)
if (!empty($ivrs)) {
$lines[] = "; --- Test direct des SVI/IVR depuis l'interne ---";
$lines[] = "; --- Test direct des SVI/IVR depuis l'interne (*801, *802...) ---";
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,Set(CHANNEL(language)={$sysLang})";
$lines[] = " same => n,Goto(ivr-{$ivrSlug},s,1)";
}
$lines[] = "";
@@ -1500,6 +1506,7 @@ class ConfigGenerator
$lines[] = "; --- Serveur Vocal Interactif : {$ivr['name']} ---";
$lines[] = "[ivr-{$ivrSlug}]";
$lines[] = "exten => s,1,NoOp(SVI {$ivr['name']})";
$lines[] = " same => n,Set(CHANNEL(language)={$sysLang})";
$lines[] = " same => n,Answer()";
$lines[] = " same => n,Wait(1)";
$lines[] = " same => n,Set(IVR_INVALID_COUNT=0)";
@@ -1600,9 +1607,6 @@ class ConfigGenerator
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
*/
@@ -2223,18 +2227,24 @@ class ConfigGenerator
$stmt = $this->db->prepare('SELECT slug FROM sounds WHERE id = :id');
$stmt->execute([':id' => (int)$soundRef]);
$slug = $stmt->fetchColumn();
return $slug ? "custom/{$slug}" : $fallback;
return $slug ? "/var/lib/asterisk/sounds/custom/{$slug}" : $fallback;
}
$ref = trim((string)$soundRef);
if (str_starts_with($ref, 'sound-')) {
return $this->resolveSoundFile(substr($ref, 6), $fallback);
}
if (str_starts_with($ref, 'custom/')) {
return "/var/lib/asterisk/sounds/{$ref}";
}
if (str_starts_with($ref, '/var/lib/asterisk/sounds/custom/')) {
return $ref;
}
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 "/var/lib/asterisk/sounds/custom/{$slug}";
}
}
return $ref ?: $fallback;
+82 -5
View File
@@ -70,17 +70,22 @@ class SystemChecker
: 'Accès refusé en écriture dans /etc/asterisk pour PHP-FPM',
];
// 5. Directives #tryinclude dans pjsip.conf et extensions.conf
// 5. Directives #tryinclude dans pjsip.conf, extensions.conf, voicemail.conf et modules.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');
$vmMain = @file_get_contents('/etc/asterisk/voicemail.conf') ?: '';
$modMain = @file_get_contents('/etc/asterisk/modules.conf') ?: '';
$incOk = str_contains($pjsipMain, 'pjsip.custom.conf')
&& str_contains($extMain, 'extensions.custom.conf')
&& str_contains($vmMain, 'voicemail.custom.conf')
&& str_contains($modMain, 'app_voicemail_odbc.so');
$checks[] = [
'id' => 'asterisk_includes',
'label' => 'Inclusions natives (#tryinclude *.custom.conf)',
'label' => 'Inclusions Asterisk & Voicemail (#tryinclude & modules.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',
? 'pjsip.conf, extensions.conf, voicemail.conf et modules.conf configurés'
: 'Directive #tryinclude manquante ou conflit modules.conf détecté',
];
// 6. Certificats TLS (Let's Encrypt ou Certificat local Debian)
@@ -142,6 +147,25 @@ class SystemChecker
public static function ensureAsteriskIncludes(): void
{
// 1. modules.conf: Désactiver les modules voicemail ODBC/IMAP incompatibles sur Debian (évite collision bloquante avec app_voicemail.so)
$modConf = '/etc/asterisk/modules.conf';
if (is_file($modConf) && is_writable($modConf)) {
$content = (string)file_get_contents($modConf);
$changed = false;
if (!str_contains($content, 'app_voicemail_odbc.so')) {
if (preg_match('/\[modules\]/i', $content)) {
$content = preg_replace('/\[modules\]/i', "[modules]\nnoload => app_voicemail_odbc.so\nnoload => app_voicemail_imap.so", $content, 1);
$changed = true;
} else {
$content = "[modules]\nautoload=yes\nnoload => app_voicemail_odbc.so\nnoload => app_voicemail_imap.so\n\n" . $content;
$changed = true;
}
}
if ($changed) {
file_put_contents($modConf, $content);
}
}
$pjsipConf = '/etc/asterisk/pjsip.conf';
if (is_file($pjsipConf) && is_writable($pjsipConf)) {
$content = (string)file_get_contents($pjsipConf);
@@ -177,8 +201,20 @@ class SystemChecker
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);
if (preg_match('/^;\s*passwordlocation\s*=\s*spooldir/m', $cleaned)) {
$cleaned = preg_replace('/^;\s*passwordlocation\s*=\s*spooldir/m', 'passwordlocation=spooldir', $cleaned);
} elseif (!str_contains($cleaned, 'passwordlocation=spooldir') && !str_contains($cleaned, 'passwordlocation = spooldir')) {
if (preg_match('/\[general\]/i', $cleaned)) {
$cleaned = preg_replace('/\[general\]/i', "[general]\npasswordlocation=spooldir", $cleaned, 1);
}
}
if (preg_match('/^;\s*externpassnotify\s*=.*/m', $cleaned)) {
$cleaned = preg_replace('/^;\s*externpassnotify\s*=.*/m', 'externpassnotify=/usr/bin/php /var/www/nexus-pbx/scripts/voicemail_pass_sync.php', $cleaned);
} elseif (!str_contains($cleaned, 'externpassnotify=')) {
if (preg_match('/\[general\]/i', $cleaned)) {
$cleaned = preg_replace('/\[general\]/i', "[general]\nexternpassnotify=/usr/bin/php /var/www/nexus-pbx/scripts/voicemail_pass_sync.php", $cleaned, 1);
}
}
if (!str_contains($cleaned, 'voicemail.custom.conf')) {
$cleaned = rtrim($cleaned) . "\n\n#tryinclude \"voicemail.custom.conf\"\n";
}
@@ -187,6 +223,47 @@ class SystemChecker
}
}
@mkdir('/var/spool/asterisk/voicemail/default', 0775, true);
// Liens vers les répertoires de sons Debian (/usr/share/asterisk/sounds -> /var/lib/asterisk/sounds)
@mkdir('/var/lib/asterisk/sounds', 0775, true);
@mkdir('/var/lib/asterisk/sounds/custom', 0775, true);
@mkdir('/var/lib/asterisk/moh', 0775, true);
foreach (['fr', 'en', 'es'] as $lang) {
$src = "/usr/share/asterisk/sounds/{$lang}";
$dst = "/var/lib/asterisk/sounds/{$lang}";
if (is_dir($src) && !file_exists($dst)) {
@symlink($src, $dst);
}
if (is_dir($dst) && !file_exists("{$dst}/custom")) {
@symlink('/var/lib/asterisk/sounds/custom', "{$dst}/custom");
}
}
if (!file_exists('/usr/share/asterisk/sounds/custom')) {
@symlink('/var/lib/asterisk/sounds/custom', '/usr/share/asterisk/sounds/custom');
}
// Synchroniser manager.conf avec le mot de passe AMI de la base SQLite si nécessaire (ex: après restauration de backup)
$mgrConf = '/etc/asterisk/manager.conf';
if (is_file($mgrConf) && is_writable($mgrConf)) {
try {
$db = Database::getConnection();
$st = $db->query('SELECT ami_user, ami_secret FROM settings WHERE id = 1')->fetch();
if (!empty($st['ami_secret'])) {
$sec = trim((string)$st['ami_secret']);
$usr = trim((string)($st['ami_user'] ?? 'pbxgui')) ?: 'pbxgui';
$content = (string)file_get_contents($mgrConf);
if (str_contains($content, "[{$usr}]")) {
$updated = preg_replace("/(\[{$usr}\][^\[]*secret\s*=\s*)([^\r\n]+)/i", "\${1}{$sec}", $content);
if ($updated !== null && $updated !== $content) {
file_put_contents($mgrConf, $updated);
@exec('asterisk -rx "manager reload" 2>/dev/null || true');
}
}
}
} catch (\Throwable) {}
}
$astConf = '/etc/asterisk/asterisk.conf';
if (is_file($astConf) && is_writable($astConf)) {
$content = (string)file_get_contents($astConf);
+36 -4
View File
@@ -298,17 +298,19 @@ class SystemMetrics
}
}
$seenExts = [];
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)) {
// Supporte sip:, sips:, sans préfixe, tous types de hash et RTT (numérique, nan, N/A ou absent)
if (preg_match('/Contact:\s+([^\s\/]+)\/(?:sips?:)?([^\s]+)\s+([a-zA-Z0-9]+)\s+(\S+)(?:\s+(\S+))?/i', $line, $m)) {
$extNum = $m[1];
$uri = $m[2];
$status = $m[4];
$rttRaw = $m[5] ?? null;
$isAvail = (stripos($status, 'Avail') !== false && stripos($status, 'Unavail') === false) || stripos($status, 'NonQual') !== false;
if ($isAvail) {
$onlineCount++;
}
$seenExts[$extNum] = true;
$meta = $contactMeta[$extNum] ?? [];
$fullUri = $meta['uri'] ?? ("sip:" . $uri);
@@ -330,7 +332,37 @@ class SystemMetrics
'user_agent' => $userAgent,
'active_transport' => $activeTransport,
'status' => $status,
'rtt_ms' => is_numeric($m[5]) ? round((float)$m[5], 1) : null,
'rtt_ms' => ($rttRaw !== null && is_numeric($rttRaw)) ? round((float)$rttRaw, 1) : null,
];
}
}
// Filet de sécurité 100% infaillible : Tous les contacts enregistrés dans AstDB (/registrar/contact)
// non encore répertoriés (par exemple si qualify est désactivé ou RTT non disponible)
$nowTime = time();
foreach ($contactMeta as $extNum => $meta) {
if (isset($seenExts[$extNum])) {
continue;
}
$exp = (int)($meta['expiration_time'] ?? 0);
if ($exp === 0 || $exp > $nowTime) {
$onlineCount++;
$fullUri = $meta['uri'] ?? '';
$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' => (string)$extNum,
'uri' => $fullUri,
'user_agent' => $meta['user_agent'] ?? '',
'active_transport' => $activeTransport,
'status' => 'Avail',
'rtt_ms' => null,
];
}
}
+9 -5
View File
@@ -1,12 +1,16 @@
{
"version": "1.4.5",
"version": "1.6.0",
"codename": "Helios",
"release_date": "2026-11-04",
"min_php_version": "8.2",
"changelog": [
"Application instantanée des mots de passe SIP et configurations des postes dans Asterisk (rechargement PJSIP en temps réel)",
"Suppression du délai d'application sur les postes, groupes de sonnerie et groupes de téléavertissement",
"Fiabilisation maximale de la mise à jour (rechargement web asynchrone non-bloquant et gestion d'erreurs tolérante)",
"Nouvelle boîte modale élégante et personnalisée pour confirmer le lancement des mises à jour"
"Nouveau module Journal d'Appels & CDR : traçabilité complète des flux entrants, sortants, internes et services avec détection automatique des Trunks SIP et postes associés",
"Filtres multicritères CDR en temps réel (période, direction, statut décroché/manqué, recherche textuelle) et export CSV Excel",
"Optimisation TLS SIP PJSIP : négociation universelle TLSv1.2/TLSv1.3 (method=sslv23)",
"Suppression de CertificateRequest en mode TLS standard pour compatibilité totale avec les téléphones IP Yealink, Fanvil et Grandstream",
"Correction de l'émission et du renouvellement Let's Encrypt / Certbot ACME (privilèges sudo et permissions d'accès aux certificats)",
"Plafond d'expiration d'enregistrement SIP standard (maximum_expiration = 300s, default = 120s) garantissant la reconnexion automatique rapide de tous les postes (1 à 3 min)",
"Nettoyage et isolation stricte des sauvegardes (base NexusPBX, sons, spools voicemail, certificats)",
"Résolution définitive et propre des conflits de préfixes Dialplan sur les SVI/IVR (*801-*899)"
]
}