Compare commits

..
65 Commits
Author SHA1 Message Date
sorcier 8efeec66ca release(v2.0.0): merge dev into main - NexusPBX 2.0 Pulsar 2026-11-11 13:04:38 +01:00
sorcier 7922e338e3 docs(release): finalize v2.0.0 Pulsar changelog and release date 2026-11-11 13:04:21 +01:00
sorcier d229aaf816 feat(ring-groups): allow direct voicemail redirection on no-answer failover
Add vm-<ext> destination support in UI destination picker, API validation, and [app-voicemail] Asterisk dialplan context. Refs #2
2026-11-11 12:40:28 +01:00
sorcier 91716e5233 release(v1.6.6): apply PJSIP RTP timeouts, SIP timers and TLS/NAT keepalives for off-network phones 2026-11-10 11:16:29 +01:00
sorcier 3ef7ed0010 perf(pjsip): optimize NAT/TLS keepalives and remote registration expiry for off-network phones 2026-11-10 11:14:38 +01:00
sorcier c79faadd99 fix(cluster): mirror voicemails and custom sounds using rsync --delete to propagate deletions 2026-11-07 02:00:38 +01:00
sorcier 4bd0f8b095 fix(cluster): purge subscription_persistence in AstDB replication to prevent dead BLF dialogues 2026-11-07 01:49:10 +01:00
sorcier d1d42f75b5 feat(cluster): add voicemail & fail2ban sync and automatic failback re-sync reconciliation 2026-11-07 01:26:02 +01:00
sorcier 54fc495fa7 fix(cluster): bind PJSIP transports to VIP and announce VIP signaling address in HA mode 2026-11-06 16:57:25 +01:00
sorcier 2ed38846ae release(v2.0.0): bump version to 2.0.0 codename Pulsar 2026-11-06 11:23:51 +01:00
sorcier e54de57105 fix(cluster): strict SIP isolation on standby node and progressive BLF wake on master to eliminate stuck BLF lights 2026-11-06 09:54:39 +01:00
sorcier fd15f05b7a docs(cluster): clarify SIP registration vs BLF subscription timer in HA settings 2026-11-06 02:41:53 +01:00
sorcier 040a4d646f fix(api): fix endpoint quoting in pjsip send notify command 2026-11-06 02:05:33 +01:00
sorcier 7fd75fbff4 feat(extensions): add Redémarrer action with call detection and confirmation modal 2026-11-06 02:01:37 +01:00
sorcier 1908af5d13 refactor(sip): keep RTP timeout settings exclusively in SIP & NAT settings 2026-11-06 01:50:28 +01:00
sorcier fa8af27e46 feat(system): add RTP Timeout and RTP Hold Timeout directly in System Settings form 2026-11-06 01:47:02 +01:00
sorcier c1805c9076 feat(sip): make rtp_timeout_hold configurable in Web UI and default to 0 (unlimited hold) 2026-11-06 01:40:31 +01:00
sorcier 767fea46b7 fix(cluster): auto-ensure blf_expiry and peer_port columns exist in ClusterManager constructor 2026-11-06 01:21:03 +01:00
sorcier f14185d597 feat(cluster): add configurable BLF expiry interval in HA menu with 120s default and automatic check-sync notify on failover 2026-11-06 01:20:16 +01:00
sorcier c1c6eab03c feat(pjsip): set default hold timeout to 600s (10 min) and add rtp_timeout_hold to settings schema 2026-11-06 00:29:19 +01:00
sorcier 1d2d6b1911 fix(ha): auto-purge zombie channels on HA transition and enforce PJSIP rtp_timeout to fix stuck BLF keys 2026-11-06 00:23:35 +01:00
sorcier 2db19ed2dd fix(cluster): use native sqlite3 .restore API to eliminate WAL/SHM desynchronization and corruption during live sync 2026-11-06 00:10:44 +01:00
sorcier 2db63d66ce feat(update): align update script with install script for HA cluster prerequisites 2026-11-06 00:00:33 +01:00
sorcier d2e3be3356 perf(db): eliminate redundant DDL execution on every request and add nexus-cluster to update.sh sudoers 2026-11-05 23:48:53 +01:00
sorcier c93b52d99c perf(core): eliminate PHP session file lock contention on API/metrics, tune PHP-FPM pool, and remove redundant Keepalived restarts on sync 2026-11-05 23:46:02 +01:00
sorcier 0b654c885c fix(cluster): ensure only VIP holder pushes sync and prevent deadlocks with flock and strict VIP check 2026-11-05 23:41:54 +01:00
sorcier 0b740da87e feat(cluster): production-grade atomic VACUUM replication, pre-validation integrity check, Asterisk database query injection, and live reload 2026-11-05 23:38:03 +01:00
sorcier a537fc6469 fix(cluster): ensure AstDB transactional sync, auto-healing, and Gratuitous ARP on master take-over 2026-11-05 23:28:53 +01:00
sorcier 062495a162 chore: ignore database .bak files in storage 2026-11-05 22:57:44 +01:00
sorcier c9ce4d7c74 feat(cluster): add Asterisk astdb.sqlite3 live contacts sync every 2min 2026-11-05 22:54:43 +01:00
sorcier 3f29ac6d2e feat(cluster): real-time auto-sync on apply_config + 5min cron periodic sync 2026-11-05 19:58:58 +01:00
sorcier 4c49cf8e1c feat(installer): integrate full HA Keepalived setup, sysctl, sudoers and cluster helper into install.sh 2026-11-05 19:55:15 +01:00
sorcier 3edcabff7c fix(cluster): set keepalived vrrp_script weight to -20 for instant failover 2026-11-05 19:38:35 +01:00
sorcier 940ff800ad fix(cluster): update Asterisk CLI health check command to core show uptime 2026-11-05 19:37:43 +01:00
sorcier b76490a4dc fix(cluster): support custom peer port, fix netmask & vrid persistence, and fix static call 2026-11-05 19:35:10 +01:00
sorcier da0805fd5f fix(ha): orchestration Keepalived root, support unicast VRRP, preservation identite backup et synchro HTTPS 2026-11-05 19:22:16 +01:00
sorcier 93f842a363 fix(cluster): inclusion de ClusterManager et autoloader PSR-4 automatique dans api.php et index.php 2026-11-05 19:02:09 +01:00
sorcier 521f21dc9c feat(cluster): module Haute Disponibilité (Cluster HA Actif/Passif Keepalived VRRP & Réplication temps réel) 2026-11-05 14:32:35 +01:00
sorcier bee2055b62 Merge branch main into dev (v1.6.5) 2026-11-05 14:02:30 +01:00
sorcier 6aa1d5d796 fix(trunks): suppression de callerid dans endpoint trunk pour preserver l appelant reel entrant (v1.6.5) 2026-11-05 14:02:28 +01:00
sorcier 45f546604e Merge branch main into dev 2026-11-05 12:45:08 +01:00
sorcier c33d13a271 fix(dialplan): trunk fallback callerid s applique systematiquement si la route n a pas de cid specifique 2026-11-05 12:45:06 +01:00
sorcier 74e29c690a Merge branch main into dev (v1.6.4) 2026-11-05 12:21:27 +01:00
sorcier 587511bd67 fix(trunks): ne pas forcer from_user si vide pour preserver le Caller ID Name dans From (v1.6.4) 2026-11-05 12:21:25 +01:00
sorcier 231bef8330 Merge branch 'main' into dev (v1.6.3 fix CallerID CNAM & trust_id_outbound) 2026-11-05 12:08:25 +01:00
sorcier 5e80461d12 fix(trunks,outbound): activation trust_id_outbound et formatage strict Caller ID Name (CNAM) pour trunks SIP (v1.6.3) 2026-11-05 12:08:11 +01:00
sorcier 18368027ee feat(dev): add dev environment detection and visual indicators 2026-11-05 00:57:11 +01:00
sorcier 4caef3abef chore(cleanup): remove obsolete scripts (fix.sh, setup-debian.sh) and update README architecture 2026-11-05 00:42:10 +01:00
sorcier 986e8e2e3c fix(backup): dynamic backup versioning and full Disaster Recovery pre-update backup (v1.6.2) 2026-11-05 00:27:47 +01:00
sorcier 469112d6ee chore(release): bump version to 1.6.1 (official logo, Asterisk tagline, CDR script permissions) 2026-11-05 00:21:01 +01:00
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
26 changed files with 4055 additions and 316 deletions

No files matched your search

+7
View File
@@ -5,6 +5,8 @@
/storage/*.sqlite-shm
/storage/*.sqlite-wal
/storage/*.prerestore.*
/storage/*.bak
*.bak
# Données dynamiques et métriques
/storage/metrics_prev.json
@@ -38,3 +40,8 @@ Thumbs.db
.idea/
.vscode/
# Environnement local de développement (exclu de Git)
/.dev
/.env
/.env.local
+12 -8
View File
@@ -47,30 +47,34 @@
```text
nexus-pbx/
├── install.sh # Script d'installation automatisé tout-en-un (Debian 12 / 13)
├── public/ # Racine Web Nginx
│ ├── index.php # Interface Web SPA moderne
│ ├── api.php # API RESTful backend
│ ├── phonebook.php # Générateur XML Yealink Remote Phonebook
│ ├── assets/ # JavaScript (app.js), styles Tailwind CSS
│ ├── assets/ # Logo, Favicon, JS (app.js), styles Tailwind CSS
│ └── avatars/ # Photos des contacts de l'annuaire
├── src/ # Classes PHP d'orchestration
│ ├── AmiClient.php # Client Asterisk Manager Interface (AMI)
│ ├── Auth.php # Authentification & sessions
│ ├── BackupManager.php # Moteur de sauvegarde locale & distante SSH
│ ├── CertificateManager.php # Gestionnaire SSL & Certbot
│ ├── BackupManager.php # Moteur Disaster Recovery & sauvegardes
│ ├── CdrManager.php # Moteur Journal d'appels & CDR
│ ├── CertificateManager.php # Gestionnaire SSL & Certbot ACME
│ ├── ConfigGenerator.php # Générateur dynamique de conf Asterisk (pjsip, extensions)
│ ├── Database.php # Connexion PDO SQLite
│ ├── FirewallManager.php # Moteur Pare-feu & règles Nginx
│ ├── FirewallManager.php # Moteur Pare-feu Fail2ban & Nginx
│ ├── PhonebookManager.php # Moteur d'annuaire et export XML
│ ├── SystemChecker.php # Diagnostic de santé système
│ └── SystemMetrics.php # Monitoring CPU, RAM, Disque, Asterisk
├── scripts/ # Scripts CLI et daemons système
│ ├── setup-debian.sh # Script d'installation automatisé Debian 12 / 13
│ ├── nexus-firewall # Script de gestion iptables/nftables
│ └── nexus_autobackup.php# Cronjob de sauvegarde automatique
│ ├── update.sh # Script de mise à jour OTA (nexus-update)
│ ├── nexus-firewall # Outil CLI Pare-feu & Fail2ban
│ ├── nexus-banner.sh # Bannière d'accueil interactive SSH/Console
│ ├── nexus_autobackup.php# Cronjob de sauvegarde automatique
│ └── voicemail_pass_sync.php # Synchronisation code PIN messagerie vocale
└── storage/ # Données persistantes (exclues du Git)
├── database.sqlite # Base de données applicative
├── backups/ # Archives de sauvegarde
├── system_backups/ # Archives de sauvegarde Disaster Recovery
└── sounds/ # Fichiers audio MoH et annonces
```
+68 -15
View File
@@ -64,6 +64,11 @@ apt-get install -y --no-install-recommends \
sox \
sshpass
# Composants optionnels Haute Disponibilité (Cluster HA)
apt-get install -y --no-install-recommends keepalived iputils-arping 2>/dev/null || true
systemctl stop keepalived >/dev/null 2>&1 || true
systemctl disable keepalived >/dev/null 2>&1 || true
# Vérification et installation d'Asterisk
echo -e "${C_GREEN} -> Installation du moteur Asterisk PBX...${C_RESET}"
if ! apt-cache policy asterisk 2>/dev/null | grep -q "Candidate: [0-9]"; then
@@ -126,11 +131,14 @@ fi
echo -e "${C_GREEN}[2/8] Préparation et déploiement des fichiers NexusPBX...${C_RESET}"
TARGET_DIR="/var/www/nexus-pbx"
INSTALL_BRANCH="${1:-${INSTALL_BRANCH:-dev}}"
if [ -d "$TARGET_DIR/.git" ]; then
echo " -> Dépôt NexusPBX déjà présent dans $TARGET_DIR, mise à jour..."
echo " -> Dépôt NexusPBX déjà présent dans $TARGET_DIR, mise à jour (branche: $INSTALL_BRANCH)..."
cd "$TARGET_DIR"
git config --global --add safe.directory "$TARGET_DIR" 2>/dev/null || true
git pull origin main 2>/dev/null || true
git checkout "$INSTALL_BRANCH" 2>/dev/null || true
git pull origin "$INSTALL_BRANCH" 2>/dev/null || true
elif [ -d "$PWD/public" ] && [ -d "$PWD/src" ]; then
echo " -> Exécution depuis le dossier source, synchronisation vers $TARGET_DIR..."
if [ "$PWD" != "$TARGET_DIR" ]; then
@@ -139,13 +147,17 @@ elif [ -d "$PWD/public" ] && [ -d "$PWD/src" ]; then
cd "$TARGET_DIR"
fi
else
echo " -> Clonage du dépôt public officiel depuis https://git.sorcier-research.com..."
echo " -> Clonage du dépôt public officiel (branche: $INSTALL_BRANCH)..."
rm -rf "$TARGET_DIR"
git clone https://git.sorcier-research.com/sorcier/NexusPBX.git "$TARGET_DIR"
git clone -b "$INSTALL_BRANCH" https://git.sorcier-research.com/sorcier/NexusPBX.git "$TARGET_DIR"
git config --system --add safe.directory "$TARGET_DIR" 2>/dev/null || true
cd "$TARGET_DIR"
fi
if [ "$INSTALL_BRANCH" = "dev" ]; then
touch "$TARGET_DIR/.dev"
fi
# 4. Configuration des utilisateurs, groupes et arborescence de stockage
echo -e "${C_GREEN}[3/8] Configuration des permissions et de l'arborescence...${C_RESET}"
usermod -aG asterisk www-data 2>/dev/null || true
@@ -159,12 +171,18 @@ 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
mkdir -p /etc/keepalived/scripts
mkdir -p "$TARGET_DIR/scripts/cluster"
touch /var/log/nexus_cluster.log
chmod 666 /var/log/nexus_cluster.log
sysctl -w net.ipv4.ip_nonlocal_bind=1 >/dev/null 2>&1 || true
echo "net.ipv4.ip_nonlocal_bind=1" > /etc/sysctl.d/99-nexus-ha.conf 2>/dev/null || true
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,16 +198,16 @@ 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
# Privilèges spécifiques NexusPBX pour l'utilisateur Web www-data
www-data ALL=(ALL) NOPASSWD: /usr/sbin/asterisk, /usr/sbin/nginx, /usr/bin/systemctl, /usr/bin/fail2ban-client, /bin/fail2ban-client, /var/www/nexus-pbx/scripts/nexus-firewall, /usr/bin/certbot, /usr/sbin/nft, /usr/sbin/iptables, /var/www/nexus-pbx/scripts/update.sh, /usr/local/bin/nexus-update
www-data ALL=(ALL) NOPASSWD: /usr/sbin/asterisk, /usr/sbin/nginx, /usr/bin/systemctl, /usr/bin/fail2ban-client, /bin/fail2ban-client, /var/www/nexus-pbx/scripts/nexus-firewall, /usr/bin/certbot, /usr/sbin/nft, /usr/sbin/iptables, /var/www/nexus-pbx/scripts/update.sh, /usr/local/bin/nexus-update, /var/www/nexus-pbx/scripts/nexus-cluster
EOF
chmod 0440 /etc/sudoers.d/nexus-pbx
@@ -201,12 +219,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 /etc/keepalived
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
@@ -329,6 +352,9 @@ 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
@@ -462,11 +488,28 @@ EOF
chmod 0644 /etc/cron.d/nexus-backup
touch /var/log/nexus_backup.log && chown www-data:asterisk /var/log/nexus_backup.log && chmod 664 /var/log/nexus_backup.log
# Cron synchronisation Cluster HA toutes les 2 minutes (synchronisation base + astdb contacts)
cat << 'EOF' > /etc/cron.d/nexus-cluster-sync
# Synchronisation périodique du cluster NexusPBX HA vers le nœud Backup
*/2 * * * * www-data /usr/bin/php /var/www/nexus-pbx/scripts/nexus_cluster_sync.php >> /var/log/nexus_cluster.log 2>&1
EOF
chmod 0644 /etc/cron.d/nexus-cluster-sync
# CLI symlinks
chmod +x "$TARGET_DIR/scripts/nexus-firewall" "$TARGET_DIR/scripts/nexus-banner.sh" "$TARGET_DIR/scripts/update.sh"
chmod +x "$TARGET_DIR/scripts/nexus-firewall" "$TARGET_DIR/scripts/nexus-banner.sh" "$TARGET_DIR/scripts/update.sh" "$TARGET_DIR/scripts/nexus-cluster"
chmod +x "$TARGET_DIR"/scripts/cluster/*.sh 2>/dev/null || true
ln -sf "$TARGET_DIR/scripts/nexus-firewall" /usr/local/bin/nexus-firewall
ln -sf "$TARGET_DIR/scripts/nexus-banner.sh" /usr/local/bin/nexus-banner
ln -sf "$TARGET_DIR/scripts/update.sh" /usr/local/bin/nexus-update
ln -sf "$TARGET_DIR/scripts/nexus-cluster" /usr/local/bin/nexus-cluster
# Pré-déploiement sécurisé des scripts Keepalived
if [ -d "$TARGET_DIR/scripts/cluster" ]; then
cp -f "$TARGET_DIR/scripts/cluster/"*.sh /etc/keepalived/scripts/ 2>/dev/null || true
chown -R root:root /etc/keepalived
chmod 755 /etc/keepalived /etc/keepalived/scripts
chmod 755 /etc/keepalived/scripts/*.sh 2>/dev/null || true
fi
# Bannière interactive lors de chaque connexion terminal (SSH / Console)
cat << 'EOF' > /etc/profile.d/nexus-motd.sh
@@ -481,6 +524,16 @@ chmod +x /etc/profile.d/nexus-motd.sh
echo -e "${C_GREEN}[8/8] Démarrage et activation des services système...${C_RESET}"
systemctl daemon-reload
# Optimisation de la réserve de processus PHP-FPM pour supporter la télémétrie temps réel et le cluster HA
for pool in /etc/php/*/fpm/pool.d/www.conf; do
if [ -f "$pool" ]; then
sed -i 's/^pm.max_children = .*/pm.max_children = 25/' "$pool"
sed -i 's/^pm.start_servers = .*/pm.start_servers = 4/' "$pool"
sed -i 's/^pm.min_spare_servers = .*/pm.min_spare_servers = 2/' "$pool"
sed -i 's/^pm.max_spare_servers = .*/pm.max_spare_servers = 6/' "$pool"
fi
done
# Redémarrage PHP-FPM (rechargement de l'appartenance au groupe asterisk)
systemctl restart php*-fpm 2>/dev/null || true
PHP_SVC=$(systemctl list-unit-files --type=service 2>/dev/null | grep -E "php.*-fpm" | awk '{print $1}' | head -n 1 || echo "php8.4-fpm")
+256 -12
View File
@@ -11,6 +11,18 @@ 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';
require_once __DIR__ . '/../src/ClusterManager.php';
// Autoloader PSR-4 automatique pour toutes les classes du namespace App
spl_autoload_register(function (string $class): void {
if (str_starts_with($class, 'App\\')) {
$file = __DIR__ . '/../src/' . str_replace('\\', '/', substr($class, 4)) . '.php';
if (file_exists($file)) {
require_once $file;
}
}
});
use App\Database;
use App\Auth;
@@ -22,13 +34,14 @@ use App\CertificateManager;
use App\BackupManager;
use App\FirewallManager;
use App\PhonebookManager;
use App\CdrManager;
use App\ClusterManager;
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store, no-cache, must-revalidate');
try {
$db = Database::getConnection();
Auth::startSession();
$action = trim((string)($_GET['action'] ?? ''));
$rawBody = file_get_contents('php://input');
@@ -37,6 +50,39 @@ try {
$payload = $_POST;
}
// Action inter-serveurs réplication Cluster (authentifiée par signature HMAC - aucune session requise)
if ($action === 'cluster_receive_sync') {
$signature = $_SERVER['HTTP_X_NEXUS_CLUSTER_SIGNATURE'] ?? '';
$cm = new ClusterManager($db);
$res = $cm->receiveSyncPayload($rawBody ?: '', $signature);
echo json_encode($res);
exit;
}
if ($action === 'cluster_node_status') {
$signature = $_SERVER['HTTP_X_NEXUS_CLUSTER_SIGNATURE'] ?? '';
$cm = new ClusterManager($db);
$res = $cm->getNodeStatusForPeer($rawBody ?: '', $signature);
echo json_encode($res);
exit;
}
if ($action === 'cluster_request_push') {
$signature = $_SERVER['HTTP_X_NEXUS_CLUSTER_SIGNATURE'] ?? '';
$cm = new ClusterManager($db);
$res = $cm->handlePushRequestFromPeer($rawBody ?: '', $signature);
echo json_encode($res);
exit;
}
Auth::startSession();
// Pour les requêtes en lecture seule fréquente (polling métriques / statut), libérer immédiatement le verrou de session
// Cela évite de saturer les workers PHP-FPM par des verrous de fichiers sessions concurrents
if (session_status() === PHP_SESSION_ACTIVE && ($action === 'get_metrics' || $action === 'cluster_status' || $action === 'get_system_info')) {
session_write_close();
}
// Actions publiques (Login / Logout)
if ($action === 'login') {
$username = trim((string)($payload['username'] ?? ''));
@@ -72,6 +118,7 @@ try {
exit;
}
// Toutes les autres actions requièrent une session authentifiée
if (!Auth::isAuthenticated()) {
http_response_code(401);
@@ -140,7 +187,7 @@ try {
$installedVerData = file_exists("{$appDir}/version.json")
? json_decode((string)file_get_contents("{$appDir}/version.json"), true)
: [];
$installedVer = $installedVerData['version'] ?? '1.4.8';
$installedVer = $installedVerData['version'] ?? '1.5.0';
echo json_encode([
'success' => true,
@@ -401,6 +448,87 @@ try {
]);
break;
case 'check_extension_call_status':
$extension = trim((string)($payload['extension'] ?? ''));
if ($extension === '') {
throw new InvalidArgumentException("Extension requise.");
}
$inCall = false;
$callDetails = '';
try {
$ami = AmiClient::fromDatabase($db);
$rawChannels = $ami->runCommand("core show channels concise");
$channelLines = array_filter(explode("\n", trim($rawChannels)));
foreach ($channelLines as $cline) {
$parts = explode('!', trim($cline));
$chanName = $parts[0] ?? '';
if (preg_match('#^PJSIP/' . preg_quote($extension, '#') . '[-/]#i', $chanName)) {
$inCall = true;
$state = $parts[4] ?? 'Up';
$callerId = $parts[7] ?? '';
$duration = $parts[10] ?? '';
$callDetails = "Canal : {$chanName}";
if ($callerId !== '') {
$callDetails .= " • Interlocuteur : {$callerId}";
}
if ($duration !== '') {
$callDetails .= " • Durée : {$duration}";
}
break;
}
}
if (!$inCall) {
$rawHints = $ami->runCommand("core show hints {$extension}");
if (preg_match('/' . preg_quote($extension, '/') . '@[^\n]*State:(InUse|Busy|Ringing)/i', $rawHints, $m)) {
$inCall = true;
$callDetails = "Ligne active : " . ($m[1] === 'Ringing' ? 'En sonnerie' : 'En communication');
}
}
} catch (\Throwable $e) {
// AMI indisponible
}
echo json_encode([
'success' => true,
'extension' => $extension,
'in_call' => $inCall,
'call_details' => $callDetails,
]);
break;
case 'reboot_extension':
$extension = trim((string)($payload['extension'] ?? ''));
if ($extension === '') {
throw new InvalidArgumentException("Extension requise.");
}
$stmt = $db->prepare('SELECT extension, name FROM extensions WHERE extension = :ext');
$stmt->execute([':ext' => $extension]);
$extRow = $stmt->fetch();
if (!$extRow) {
throw new InvalidArgumentException("Poste {$extension} introuvable.");
}
$cleanExt = preg_replace('/[^a-zA-Z0-9_\-]/', '', $extension);
try {
$ami = AmiClient::fromDatabase($db);
$cmdOutput = $ami->runCommand("pjsip send notify reboot endpoint {$cleanExt}");
if (stripos($cmdOutput, 'Unable to') !== false) {
throw new RuntimeException("Échec Asterisk : " . trim($cmdOutput));
}
echo json_encode([
'success' => true,
'message' => "Ordre de redémarrage envoyé au poste {$extension} ({$extRow['name']}).",
'details' => trim($cmdOutput),
]);
} catch (\Throwable $e) {
throw new RuntimeException("Erreur lors de l'envoi de la commande de redémarrage : " . $e->getMessage());
}
break;
case 'save_group':
$id = isset($payload['id']) && $payload['id'] !== '' ? (int)$payload['id'] : null;
$extension = trim((string)($payload['extension'] ?? ''));
@@ -416,6 +544,14 @@ try {
if ($name === '') {
throw new InvalidArgumentException("Le nom du groupe d'appel est obligatoire.");
}
if (str_starts_with($failover, 'vm-')) {
$vmTarget = substr($failover, 3);
$vmStmt = $db->prepare('SELECT voicemail_enabled FROM extensions WHERE extension = :ext AND enabled = 1');
$vmStmt->execute([':ext' => $vmTarget]);
if (!(int)$vmStmt->fetchColumn()) {
throw new InvalidArgumentException("Le répondeur du poste {$vmTarget} n'existe pas ou n'est pas activé.");
}
}
if ($id) {
$stmt = $db->prepare('
@@ -1153,6 +1289,7 @@ try {
$send_rpid = (int)!empty($payload['send_rpid']);
$send_diversion = isset($payload['send_diversion']) ? (int)!empty($payload['send_diversion']) : 1;
$trust_id_inbound = isset($payload['trust_id_inbound']) ? (int)!empty($payload['trust_id_inbound']) : 1;
$trust_id_outbound = isset($payload['trust_id_outbound']) ? (int)!empty($payload['trust_id_outbound']) : 1;
$max_retries = max(0, (int)($payload['max_retries'] ?? 10000));
$forbidden_retry_interval = max(0, (int)($payload['forbidden_retry_interval'] ?? 600));
$fatal_retry_interval = max(0, (int)($payload['fatal_retry_interval'] ?? 0));
@@ -1226,6 +1363,7 @@ try {
':send_rpid' => $send_rpid,
':send_diversion' => $send_diversion,
':trust_id_inbound' => $trust_id_inbound,
':trust_id_outbound' => $trust_id_outbound,
':codecs' => $codecs,
':dtmf_mode' => $dtmf_mode,
':media_encryption' => $media_encryption,
@@ -1267,7 +1405,7 @@ try {
fatal_retry_interval = :fatal_retry_interval, line_support = :line_support,
from_user = :from_user, from_domain = :from_domain, outbound_callerid = :outbound_callerid,
send_pai = :send_pai, send_rpid = :send_rpid, send_diversion = :send_diversion,
trust_id_inbound = :trust_id_inbound, codecs = :codecs, dtmf_mode = :dtmf_mode,
trust_id_inbound = :trust_id_inbound, trust_id_outbound = :trust_id_outbound, codecs = :codecs, dtmf_mode = :dtmf_mode,
media_encryption = :media_encryption, nat_symmetric = :nat_symmetric,
direct_media = :direct_media, qualify_frequency = :qualify_frequency,
max_channels = :max_channels, timers = :timers, language = :language,
@@ -1289,7 +1427,7 @@ try {
registration_mode, register, server_uri, client_uri, contact_user, expiration, retry_interval,
max_retries, forbidden_retry_interval, fatal_retry_interval, line_support,
from_user, from_domain, outbound_callerid, send_pai, send_rpid, send_diversion,
trust_id_inbound, codecs, dtmf_mode, media_encryption, nat_symmetric, direct_media,
trust_id_inbound, trust_id_outbound, codecs, dtmf_mode, media_encryption, nat_symmetric, direct_media,
qualify_frequency, max_channels, timers, language, rtp_keepalive, t38_udptl,
fax_detect, inband_progress, match_ips, context, inbound_dest, did_routes_json,
outbound_prefix, custom_endpoint_params, custom_reg_params, custom_aor_params, moh_sound_id,
@@ -1299,7 +1437,7 @@ try {
:registration_mode, :register, :server_uri, :client_uri, :contact_user, :expiration, :retry_interval,
:max_retries, :forbidden_retry_interval, :fatal_retry_interval, :line_support,
:from_user, :from_domain, :outbound_callerid, :send_pai, :send_rpid, :send_diversion,
:trust_id_inbound, :codecs, :dtmf_mode, :media_encryption, :nat_symmetric, :direct_media,
:trust_id_inbound, :trust_id_outbound, :codecs, :dtmf_mode, :media_encryption, :nat_symmetric, :direct_media,
:qualify_frequency, :max_channels, :timers, :language, :rtp_keepalive, :t38_udptl,
:fax_detect, :inband_progress, :match_ips, :context, :inbound_dest, :did_routes_json,
:outbound_prefix, :custom_endpoint_params, :custom_reg_params, :custom_aor_params, :moh_sound_id,
@@ -1316,7 +1454,7 @@ try {
fatal_retry_interval = excluded.fatal_retry_interval, line_support = excluded.line_support,
from_user = excluded.from_user, from_domain = excluded.from_domain, outbound_callerid = excluded.outbound_callerid,
send_pai = excluded.send_pai, send_rpid = excluded.send_rpid, send_diversion = excluded.send_diversion,
trust_id_inbound = excluded.trust_id_inbound, codecs = excluded.codecs, dtmf_mode = excluded.dtmf_mode,
trust_id_inbound = excluded.trust_id_inbound, trust_id_outbound = excluded.trust_id_outbound, codecs = excluded.codecs, dtmf_mode = excluded.dtmf_mode,
media_encryption = excluded.media_encryption, nat_symmetric = excluded.nat_symmetric,
direct_media = excluded.direct_media, qualify_frequency = excluded.qualify_frequency,
max_channels = excluded.max_channels, timers = excluded.timers, language = excluded.language,
@@ -1734,6 +1872,14 @@ try {
]);
SystemChecker::ensureAsteriskIncludes();
$generator->writeAllConfigs();
try {
$ami = AmiClient::fromDatabase($db);
$ami->reloadModule('res_pjsip.so');
$ami->reloadModule('res_rtp_asterisk.so');
} catch (\Throwable $e) {
// Ignore reload error if Asterisk is not running
}
if (!empty($payload['new_admin_password'])) {
$newPass = (string)$payload['new_admin_password'];
@@ -1767,12 +1913,33 @@ try {
$ami = AmiClient::fromDatabase($db);
$reloadInfo = $ami->reloadPjsipAndDialplan();
// Actualisation de l'horodatage de modification en base pour la réconciliation cluster
try {
$db->exec("UPDATE cluster_settings SET updated_at = CURRENT_TIMESTAMP WHERE id = 1");
} catch (\Throwable) {}
// Synchronisation automatique temps réel vers le nœud pair si Cluster actif et Master
$clusterSyncMsg = null;
try {
$cm = new ClusterManager($db);
$cSet = $cm->getSettings();
$isVipActive = $cm->isVirtualIpActive($cSet['virtual_ip'] ?? '');
if (!empty($cSet['enabled']) && !empty($cSet['auto_sync']) && $isVipActive && !empty($cSet['peer_ip'])) {
$syncRes = $cm->pushSyncToPeer();
$clusterSyncMsg = $syncRes['message'] ?? 'Réplication pair OK';
}
} catch (\Throwable $ce) {
error_log("[CLUSTER] Auto-sync on apply_config error: " . $ce->getMessage());
$clusterSyncMsg = "Alerte réplication pair : " . $ce->getMessage();
}
echo json_encode([
'success' => true,
'message' => 'Configurations écrites, pile PJSIP rechargée et Re-Register SIP envoyé à tous les opérateurs !',
'message' => 'Configurations appliquées avec succès !' . ($clusterSyncMsg ? ' (' . $clusterSyncMsg . ')' : ''),
'details' => [
'files' => $writeStats,
'reload' => $reloadInfo,
'files' => $writeStats,
'reload' => $reloadInfo,
'cluster_sync' => $clusterSyncMsg,
],
]);
break;
@@ -1968,6 +2135,7 @@ try {
$rtpChecksums = !empty($payload['rtp_checksums']) ? 1 : 0;
$rtpStrict = !empty($payload['rtp_strict']) ? 1 : 0;
$rtpTimeout = max(5, (int)($payload['rtp_timeout'] ?? 30));
$rtpHoldTimeout = max(0, (int)($payload['rtp_timeout_hold'] ?? 0));
$rtpKeepalive = max(0, (int)($payload['rtp_keepalive'] ?? 15));
$allowAnonymousSip = !empty($payload['allow_anonymous_sip']) ? 1 : 0;
@@ -2002,6 +2170,7 @@ try {
rtp_checksums = :rtp_checksums,
rtp_strict = :rtp_strict,
rtp_timeout = :rtp_timeout,
rtp_timeout_hold = :rtp_timeout_hold,
rtp_keepalive = :rtp_keepalive,
allow_anonymous_sip = :anon,
updated_at = CURRENT_TIMESTAMP
@@ -2019,9 +2188,10 @@ try {
':rtp_start' => $rtpStart,
':rtp_end' => $rtpEnd,
':rtp_checksums' => $rtpChecksums,
':rtp_strict' => $rtpStrict,
':rtp_timeout' => $rtpTimeout,
':rtp_keepalive' => $rtpKeepalive,
':rtp_strict' => $rtpStrict,
':rtp_timeout' => $rtpTimeout,
':rtp_timeout_hold' => $rtpHoldTimeout,
':rtp_keepalive' => $rtpKeepalive,
':anon' => $allowAnonymousSip,
]);
@@ -2276,6 +2446,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");
@@ -2370,6 +2580,40 @@ try {
}
break;
case 'cluster_status':
$cm = new ClusterManager($db);
echo json_encode([
'success' => true,
'status' => $cm->getClusterStatus(),
]);
break;
case 'cluster_interfaces':
$cm = new ClusterManager($db);
echo json_encode([
'success' => true,
'interfaces' => $cm->detectNetworkInterfaces(),
]);
break;
case 'cluster_save':
$cm = new ClusterManager($db);
$res = $cm->saveSettings($payload);
echo json_encode($res);
break;
case 'cluster_sync_now':
$cm = new ClusterManager($db);
$res = $cm->pushSyncToPeer();
echo json_encode($res);
break;
case 'cluster_switchover':
$cm = new ClusterManager($db);
$res = $cm->triggerSwitchover();
echo json_encode($res);
break;
default:
http_response_code(400);
echo json_encode([
+734 -8
View File
@@ -951,9 +951,10 @@ function renderExtensionsTable() {
<td class="py-3.5 px-4">
<code class="px-2 py-0.5 rounded bg-anthracite-950 border border-anthracite-700 text-xs font-mono text-slate-300">${escapeHtml(ext.secret)}</code>
</td>
<td class="py-3.5 px-4 text-right space-x-2">
<button type="button" onclick="editExtension(${Number(ext.id)})" class="text-xs font-semibold text-indigo-300 hover:text-indigo-200">Modifier</button>
<button type="button" onclick="deleteExtension(${Number(ext.id)})" class="text-xs font-semibold text-red-400 hover:text-red-300">Supprimer</button>
<td class="py-3.5 px-4 text-right space-x-2 whitespace-nowrap">
<button type="button" onclick="promptRebootExtension(${Number(ext.id)})" class="text-xs font-semibold text-amber-400 hover:text-amber-300 transition">Redémarrer</button>
<button type="button" onclick="editExtension(${Number(ext.id)})" class="text-xs font-semibold text-indigo-300 hover:text-indigo-200 transition">Modifier</button>
<button type="button" onclick="deleteExtension(${Number(ext.id)})" class="text-xs font-semibold text-red-400 hover:text-red-300 transition">Supprimer</button>
</td>
</tr>
`;
@@ -1109,6 +1110,90 @@ async function deleteExtension(id, optNum = null) {
}
}
let pendingRebootExtension = null;
async function promptRebootExtension(id) {
const ext = (appState.extensions || []).find(e => Number(e.id) === Number(id));
if (!ext) return;
pendingRebootExtension = ext;
document.getElementById('reboot-modal-ext-num').textContent = ext.extension;
document.getElementById('reboot-modal-ext-name').textContent = ext.name || '';
const checkingEl = document.getElementById('reboot-modal-checking');
const warningEl = document.getElementById('reboot-modal-call-warning');
const normalEl = document.getElementById('reboot-modal-normal-info');
const btnAction = document.getElementById('btn-confirm-reboot-action');
const btnLabel = document.getElementById('reboot-confirm-btn-label');
checkingEl.classList.remove('hidden');
warningEl.classList.add('hidden');
normalEl.classList.add('hidden');
btnAction.disabled = true;
btnAction.className = 'px-5 py-2.5 rounded-xl text-xs font-bold bg-anthracite-700 text-slate-400 transition cursor-not-allowed flex items-center gap-2';
btnLabel.textContent = 'Vérification en cours...';
openModal('modal-confirm-reboot-extension');
try {
const res = await apiCall('check_extension_call_status', { extension: ext.extension });
checkingEl.classList.add('hidden');
btnAction.disabled = false;
if (res.in_call) {
warningEl.classList.remove('hidden');
normalEl.classList.add('hidden');
const detailsEl = document.getElementById('reboot-modal-call-details');
if (detailsEl) {
detailsEl.textContent = res.call_details || 'Communication active détectée sur ce combiné.';
}
btnAction.className = 'px-5 py-2.5 rounded-xl text-xs font-bold bg-rose-600 hover:bg-rose-500 text-white shadow-lg shadow-rose-600/30 transition flex items-center gap-2';
btnLabel.textContent = "Forcer le redémarrage (Couper l'appel)";
} else {
warningEl.classList.add('hidden');
normalEl.classList.remove('hidden');
btnAction.className = 'px-5 py-2.5 rounded-xl text-xs font-bold bg-amber-600 hover:bg-amber-500 text-white shadow-lg shadow-amber-600/30 transition flex items-center gap-2';
btnLabel.textContent = 'Confirmer le redémarrage';
}
} catch (err) {
checkingEl.classList.add('hidden');
normalEl.classList.remove('hidden');
btnAction.disabled = false;
btnAction.className = 'px-5 py-2.5 rounded-xl text-xs font-bold bg-amber-600 hover:bg-amber-500 text-white shadow-lg shadow-amber-600/30 transition flex items-center gap-2';
btnLabel.textContent = 'Confirmer le redémarrage';
}
}
function closeRebootModal() {
pendingRebootExtension = null;
closeModal('modal-confirm-reboot-extension');
}
async function confirmRebootExtension() {
if (!pendingRebootExtension) return;
const ext = pendingRebootExtension;
const btnAction = document.getElementById('btn-confirm-reboot-action');
const btnLabel = document.getElementById('reboot-confirm-btn-label');
if (btnAction) btnAction.disabled = true;
if (btnLabel) btnLabel.textContent = 'Envoi de l\'ordre...';
try {
const res = await apiCall('reboot_extension', { extension: ext.extension });
closeRebootModal();
showToast(res.message, 'success');
} catch (err) {
showToast(err.message, 'error');
} finally {
if (btnAction) btnAction.disabled = false;
}
}
function syncEncryptionToggle(transport, currentVal = null) {
const sel = document.getElementById('ext-encryption');
if (!sel) return;
@@ -1149,6 +1234,9 @@ function renderGroupsTable() {
const badges = members.length
? members.map(m => `<span class="px-2 py-0.5 rounded bg-indigo-500/20 text-indigo-200 border border-indigo-500/30 font-mono text-xs font-semibold">${escapeHtml(m)}</span>`).join(' ')
: '<span class="text-slate-500 text-xs italic">Aucun membre</span>';
const failoverLabel = String(grp.failover_dest || '').startsWith('vm-')
? `<span class="px-2 py-0.5 rounded bg-violet-500/20 text-violet-200 border border-violet-500/30 font-mono text-xs font-semibold">📬 Répondeur ${escapeHtml(String(grp.failover_dest).slice(3))}</span>`
: `<code class="text-slate-300">${escapeHtml(grp.failover_dest)}</code>`;
return `
<tr class="hover:bg-anthracite-750/60 transition">
@@ -1156,7 +1244,7 @@ function renderGroupsTable() {
<td class="py-3.5 px-4 font-bold text-white">${escapeHtml(grp.name)}</td>
<td class="py-3.5 px-4"><code class="text-xs text-cyan-300 font-mono">${escapeHtml(grp.strategy)}</code></td>
<td class="py-3.5 px-4"><div class="flex flex-wrap gap-1">${badges}</div></td>
<td class="py-3.5 px-4 text-xs text-slate-200">${Number(grp.ring_time)}s → <code class="text-slate-300">${escapeHtml(grp.failover_dest)}</code></td>
<td class="py-3.5 px-4 text-xs text-slate-200">${Number(grp.ring_time)}s → ${failoverLabel}</td>
<td class="py-3.5 px-4 text-right space-x-2">
<button type="button" onclick="editGroup(${Number(grp.id)})" class="text-xs font-semibold text-indigo-300 hover:text-indigo-200">Modifier</button>
<button type="button" onclick="deleteGroup(${Number(grp.id)})" class="text-xs font-semibold text-red-400 hover:text-red-300">Supprimer</button>
@@ -1808,6 +1896,21 @@ function buildDestinationOptionsHtml(selectedValue = 'ext-101') {
html += `</optgroup>`;
}
const vmExts = exts.filter(e => Number(e.voicemail_enabled) === 1);
const hasMissingVm = normVal.startsWith('vm-') && !vmExts.some(e => `vm-${e.extension}` === normVal);
if (vmExts.length > 0 || hasMissingVm) {
html += `<optgroup label="📬 Messagerie Vocale Directe (Répondeur)">`;
if (hasMissingVm) {
const missingExt = normVal.slice(3);
html += `<option value="${escapeHtml(normVal)}" selected>Répondeur: ${escapeHtml(missingExt)} (messagerie désactivée)</option>`;
}
vmExts.forEach(e => {
const val = `vm-${e.extension}`;
html += `<option value="${escapeHtml(val)}" ${normVal === val ? 'selected' : ''}>Répondeur: ${escapeHtml(e.extension)} — ${escapeHtml(e.name)}</option>`;
});
html += `</optgroup>`;
}
// Piège à Spammeurs / Musique d'attente en boucle continue (MoH)
const mohSounds = sounds.filter(s => s.usage_type === 'moh');
html += `<optgroup label="🎵 Piège à Spammeurs (Musique d'attente en boucle infinie)">`;
@@ -1863,6 +1966,8 @@ function styleDestinationSelect(selectEl) {
selectEl.className = 'trunk-did-dest w-full px-2.5 py-1.5 rounded-lg bg-fuchsia-500/15 border border-fuchsia-500/40 text-xs text-fuchsia-300 font-semibold';
} else if (val.startsWith('grp-')) {
selectEl.className = 'trunk-did-dest w-full px-2.5 py-1.5 rounded-lg bg-cyan-500/10 border border-cyan-500/40 text-xs text-cyan-300 font-semibold';
} else if (val.startsWith('vm-')) {
selectEl.className = 'trunk-did-dest w-full px-2.5 py-1.5 rounded-lg bg-violet-500/15 border border-violet-500/40 text-xs text-violet-300 font-semibold';
} else {
selectEl.className = 'trunk-did-dest w-full px-2.5 py-1.5 rounded-lg bg-emerald-500/10 border border-emerald-500/40 text-xs text-emerald-300 font-semibold';
}
@@ -2355,6 +2460,7 @@ function openTrunkModal(trunk = null) {
form.elements['send_rpid'].checked = trunk ? Number(trunk.send_rpid ?? 0) === 1 : false;
form.elements['send_diversion'].checked = trunk ? Number(trunk.send_diversion ?? 1) === 1 : true;
form.elements['trust_id_inbound'].checked = trunk ? Number(trunk.trust_id_inbound ?? 1) === 1 : true;
form.elements['trust_id_outbound'].checked = trunk ? Number(trunk.trust_id_outbound ?? 1) === 1 : true;
// Onglet 3 : Codecs, NAT, Timers, T.38 & Identify
form.elements['codecs'].value = trunk ? (trunk.codecs || 'alaw,ulaw,g722') : 'alaw,ulaw,g722';
@@ -3508,6 +3614,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)",
@@ -3521,7 +3628,8 @@ const PAGE_TITLES = {
certificates: 'Gestionnaire de Certificats SSL / TLS (Let\'s Encrypt & Imports)',
system: 'Paramètres Globaux, Diagnostic Debian & Fichiers .conf',
backups: 'Sauvegardes & Restauration Système (Disaster Recovery)',
firewall: 'Pare-feu Réseau & Sécurité Téléphonie (Fail2ban & NFTables)'
firewall: 'Pare-feu Réseau & Sécurité Téléphonie (Fail2ban & NFTables)',
cluster: 'Haute Disponibilité & Redondance (Cluster HA Keepalived)'
};
function switchTab(tabId) {
@@ -3572,7 +3680,7 @@ function switchTab(tabId) {
}
// Si on clique sur un sous-menu Système, s'assurer que le menu parent est bien déplié
if (tabId === 'system' || tabId === 'backups' || tabId === 'firewall') {
if (tabId === 'system' || tabId === 'backups' || tabId === 'firewall' || tabId === 'cluster') {
const submenu = document.getElementById('submenu-system');
const arrow = document.getElementById('arrow-menu-system');
if (submenu && submenu.classList.contains('hidden')) {
@@ -3586,11 +3694,21 @@ 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();
}
// Si onglet Cluster HA, charger les interfaces et le statut en direct
if (tabId === 'cluster') {
loadCluster();
}
toggleMobileSidebar(false);
}
@@ -3782,7 +3900,8 @@ document.addEventListener('DOMContentLoaded', () => {
payload.send_pai = form.elements['send_pai'].checked ? 1 : 0;
payload.send_rpid = form.elements['send_rpid'].checked ? 1 : 0;
payload.send_diversion = form.elements['send_diversion'].checked ? 1 : 0;
payload.trust_id_inbound = form.elements['trust_id_inbound'].checked ? 1 : 0;
payload.trust_id_inbound = form.elements['trust_id_inbound'].checked ? 1 : 0;
payload.trust_id_outbound = form.elements['trust_id_outbound'].checked ? 1 : 0;
payload.nat_symmetric = form.elements['nat_symmetric'].checked ? 1 : 0;
payload.direct_media = form.elements['direct_media'].checked ? 1 : 0;
payload.inband_progress = form.elements['inband_progress'].checked ? 1 : 0;
@@ -4138,6 +4257,7 @@ document.addEventListener('DOMContentLoaded', () => {
rtp_checksums: Number(document.getElementById('sip-rtp-checksums')?.value || 1),
rtp_keepalive: Number(document.getElementById('sip-rtp-keepalive')?.value || 15),
rtp_timeout: Number(document.getElementById('sip-rtp-timeout')?.value || 30),
rtp_timeout_hold: Number(document.getElementById('sip-rtp-timeout-hold')?.value || 0),
allow_anonymous_sip: Number(document.getElementById('sip-allow-anonymous')?.value || 0),
local_networks: localNets
};
@@ -4599,6 +4719,11 @@ function renderSipSettings() {
rtpTimeoutEl.value = (s.rtp_timeout !== undefined && s.rtp_timeout !== null) ? s.rtp_timeout : 30;
}
const rtpHoldTimeoutEl = document.getElementById('sip-rtp-timeout-hold');
if (rtpHoldTimeoutEl && document.activeElement !== rtpHoldTimeoutEl) {
rtpHoldTimeoutEl.value = (s.rtp_timeout_hold !== undefined && s.rtp_timeout_hold !== null) ? s.rtp_timeout_hold : 0;
}
const allowAnonEl = document.getElementById('sip-allow-anonymous');
if (allowAnonEl) {
allowAnonEl.value = (s.allow_anonymous_sip !== undefined && s.allow_anonymous_sip !== null) ? String(s.allow_anonymous_sip) : '0';
@@ -4916,7 +5041,13 @@ function renderBackups() {
? `<span class="px-2 py-0.5 rounded-full text-[10px] font-bold bg-emerald-500/20 text-emerald-300 border border-emerald-500/30">Local</span>`
: `<span class="px-2 py-0.5 rounded-full text-[10px] font-bold bg-purple-500/20 text-purple-300 border border-purple-500/30">Distant (SSH)</span>`;
const versionBadge = `<span class="px-1.5 py-0.2 rounded text-[10px] font-mono font-bold bg-indigo-500/20 text-indigo-300 border border-indigo-500/30">v${escapeHtml(b.version || '1.2.0')}</span>`;
const versionBadge = `<span class="px-1.5 py-0.2 rounded text-[10px] font-mono font-bold bg-indigo-500/20 text-indigo-300 border border-indigo-500/30">v${escapeHtml(b.version || '1.6.2')}</span>`;
let srcBadge = '';
if (b.source === 'pre-update') {
srcBadge = `<span class="px-1.5 py-0.2 rounded text-[10px] font-bold bg-amber-500/20 text-amber-300 border border-amber-500/30" title="Sauvegarde automatique pré-mise à jour">Pré-MàJ</span>`;
} else if (b.source === 'scheduled') {
srcBadge = `<span class="px-1.5 py-0.2 rounded text-[10px] font-bold bg-cyan-500/20 text-cyan-300 border border-cyan-500/30" title="Sauvegarde planifiée automatique">Auto</span>`;
}
return `
<tr class="hover:bg-anthracite-750 transition">
@@ -4927,6 +5058,7 @@ function renderBackups() {
</svg>
<span class="truncate max-w-xs sm:max-w-md">${escapeHtml(b.filename)}</span>
${versionBadge}
${srcBadge}
</div>
</td>
<td class="px-4 py-3 text-slate-300 whitespace-nowrap">${escapeHtml(b.date_formatted)}</td>
@@ -6073,3 +6205,597 @@ 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');
}
}
/* ==============================================================================
MODULE HAUTE DISPONIBILITÉ (CLUSTER HA ACTIF / PASSIF KEEPALIVED)
============================================================================== */
let currentClusterData = null;
async function loadCluster() {
await loadClusterInterfaces();
await refreshClusterStatus();
}
async function loadClusterInterfaces() {
try {
const res = await apiCall('cluster_interfaces');
const select = document.getElementById('cluster-interface');
if (!select || !res.interfaces) return;
const currentVal = select.value;
select.innerHTML = res.interfaces.map(iface => {
const ipDisplay = iface.primary ? `(${iface.primary}/${iface.netmask})` : '(pas d\'IP)';
return `<option value="${iface.name}">${iface.name} ${ipDisplay}</option>`;
}).join('');
if (currentVal && res.interfaces.some(i => i.name === currentVal)) {
select.value = currentVal;
}
} catch (e) {
console.error("Erreur lors du chargement des interfaces réseau :", e);
}
}
async function refreshClusterStatus() {
try {
const res = await apiCall('cluster_status');
if (!res.success || !res.status) return;
const st = res.status;
currentClusterData = st;
// Formulaire - Mise à jour des valeurs
const chkEnabled = document.getElementById('cluster-enabled');
if (chkEnabled) chkEnabled.checked = Boolean(st.enabled);
const radioRole = document.querySelector(`input[name="role"][value="${st.configured_role || 'master'}"]`);
if (radioRole) radioRole.checked = true;
const ifaceSelect = document.getElementById('cluster-interface');
if (ifaceSelect && st.interface) ifaceSelect.value = st.interface;
const vipInput = document.getElementById('cluster-virtual-ip');
if (vipInput) vipInput.value = st.virtual_ip || '';
const vmaskInput = document.getElementById('cluster-virtual-netmask');
if (vmaskInput && st.virtual_netmask) vmaskInput.value = st.virtual_netmask;
const vridInput = document.getElementById('cluster-vrid');
if (vridInput && st.vrid) vridInput.value = st.vrid;
const peerIpInput = document.getElementById('cluster-peer-ip');
if (peerIpInput) peerIpInput.value = st.peer_ip || '';
const peerPortInput = document.getElementById('cluster-peer-port');
if (peerPortInput && st.peer_port) peerPortInput.value = st.peer_port;
const autoSyncChk = document.getElementById('cluster-auto-sync');
if (autoSyncChk && st.auto_sync !== undefined) autoSyncChk.checked = Boolean(st.auto_sync);
const secretInput = document.getElementById('cluster-secret');
if (secretInput && st.cluster_secret) secretInput.value = st.cluster_secret;
const blfExpiryContainer = document.getElementById('cluster-blf-expiry-container');
const blfExpiryInput = document.getElementById('cluster-blf-expiry');
if (blfExpiryContainer && blfExpiryInput) {
if (st.enabled) {
blfExpiryContainer.classList.remove('hidden');
blfExpiryInput.value = st.blf_expiry || 120;
} else {
blfExpiryContainer.classList.add('hidden');
blfExpiryInput.value = st.blf_expiry || 1800;
}
}
if (chkEnabled && !chkEnabled.dataset.blfBound) {
chkEnabled.dataset.blfBound = '1';
chkEnabled.addEventListener('change', () => {
const c = document.getElementById('cluster-blf-expiry-container');
const inp = document.getElementById('cluster-blf-expiry');
if (chkEnabled.checked) {
if (c) c.classList.remove('hidden');
if (inp && (!inp.value || inp.value === '1800')) inp.value = 120;
} else {
if (c) c.classList.add('hidden');
if (inp) inp.value = 1800;
}
});
}
// Badges & Cartes
const headerBadge = document.getElementById('cluster-header-state-badge');
const navBadge = document.getElementById('nav-badge-cluster');
const cardRoleBadge = document.getElementById('cluster-card-role-badge');
const cardState = document.getElementById('cluster-card-state');
const cardVipInd = document.getElementById('cluster-card-vip-indicator');
const cardIface = document.getElementById('cluster-card-iface');
const cardVip = document.getElementById('cluster-card-vip');
const cardVrid = document.getElementById('cluster-card-vrid');
const cardPeerStatus = document.getElementById('cluster-card-peer-status');
const cardPeerIp = document.getElementById('cluster-card-peer-ip');
const cardPeerPing = document.getElementById('cluster-card-peer-ping');
const cardSyncStatus = document.getElementById('cluster-card-sync-status');
const cardSyncTime = document.getElementById('cluster-card-sync-time');
if (cardVrid) cardVrid.textContent = st.vrid || '51';
if (cardIface) cardIface.textContent = `Interface : ${st.interface || 'eth0'}`;
if (cardVip) cardVip.textContent = st.virtual_ip || 'Non configurée';
if (cardRoleBadge) {
cardRoleBadge.textContent = (st.configured_role || 'master').toUpperCase();
}
if (st.enabled) {
if (st.effective_state === 'MASTER') {
if (headerBadge) {
headerBadge.className = 'px-2.5 py-0.5 rounded-full text-xs font-bold bg-emerald-500/20 text-emerald-300 border border-emerald-500/30';
headerBadge.textContent = 'ACTIF (MASTER)';
}
if (navBadge) {
navBadge.className = 'ml-2 shrink-0 px-1.5 py-0.2 text-[10px] rounded-full bg-emerald-500/20 text-emerald-300 border border-emerald-500/30 font-bold';
navBadge.textContent = 'MASTER';
}
if (cardState) {
cardState.className = 'text-lg font-bold text-emerald-400 tracking-wide';
cardState.textContent = 'MASTER (ACTIF)';
}
if (cardVipInd) {
cardVipInd.className = 'text-[10px] font-mono px-1.5 py-0.5 rounded bg-emerald-950 text-emerald-300 border border-emerald-800';
cardVipInd.textContent = 'VIP: Portée';
}
} else {
if (headerBadge) {
headerBadge.className = 'px-2.5 py-0.5 rounded-full text-xs font-bold bg-blue-500/20 text-blue-300 border border-blue-500/30';
headerBadge.textContent = 'STANDBY (EN VEILLE)';
}
if (navBadge) {
navBadge.className = 'ml-2 shrink-0 px-1.5 py-0.2 text-[10px] rounded-full bg-blue-500/20 text-blue-300 border border-blue-500/30 font-bold';
navBadge.textContent = 'STANDBY';
}
if (cardState) {
cardState.className = 'text-lg font-bold text-blue-400 tracking-wide';
cardState.textContent = 'STANDBY (VEILLE)';
}
if (cardVipInd) {
cardVipInd.className = 'text-[10px] font-mono px-1.5 py-0.5 rounded bg-anthracite-900 text-slate-400';
cardVipInd.textContent = 'VIP: Écoute';
}
}
} else {
if (headerBadge) {
headerBadge.className = 'px-2.5 py-0.5 rounded-full text-xs font-bold bg-slate-700 text-slate-300';
headerBadge.textContent = 'DÉSACTIVÉ';
}
if (navBadge) {
navBadge.className = 'ml-2 shrink-0 px-1.5 py-0.2 text-[10px] rounded-full bg-anthracite-800 text-slate-400 border border-anthracite-700 font-bold';
navBadge.textContent = 'OFF';
}
if (cardState) {
cardState.className = 'text-lg font-bold text-slate-400 tracking-wide';
cardState.textContent = 'STANDALONE';
}
if (cardVipInd) {
cardVipInd.className = 'text-[10px] font-mono px-1.5 py-0.5 rounded bg-anthracite-900 text-slate-400';
cardVipInd.textContent = 'VIP: Inactive';
}
}
// Nœud Distant (Peer)
if (cardPeerIp) cardPeerIp.textContent = st.peer_ip || 'Non configuré';
if (cardPeerStatus) {
if (!st.peer_ip) {
cardPeerStatus.className = 'px-2 py-0.5 rounded text-[10px] font-bold bg-slate-700 text-slate-300';
cardPeerStatus.textContent = 'NON DÉFINI';
} else if (st.peer_reachable) {
cardPeerStatus.className = 'px-2 py-0.5 rounded text-[10px] font-bold bg-emerald-500/20 text-emerald-300 border border-emerald-500/30';
cardPeerStatus.textContent = 'JOIGNABLE';
} else {
cardPeerStatus.className = 'px-2 py-0.5 rounded text-[10px] font-bold bg-red-500/20 text-red-300 border border-red-500/30';
cardPeerStatus.textContent = 'INJOIGNABLE';
}
}
if (cardPeerPing) {
cardPeerPing.textContent = (st.peer_ping_ms !== null) ? `Latence ping : ${st.peer_ping_ms.toFixed(1)} ms` : 'Latence ping : Inaccessible';
}
// Synchronisation
if (cardSyncStatus) {
if (st.last_sync_status === 'success' || st.last_sync_status === 'synced') {
cardSyncStatus.className = 'text-sm font-bold text-emerald-400 truncate';
cardSyncStatus.textContent = 'Synchronisation OK';
} else if (st.last_sync_status === 'error') {
cardSyncStatus.className = 'text-sm font-bold text-red-400 truncate';
cardSyncStatus.textContent = 'Erreur réplication';
} else {
cardSyncStatus.className = 'text-sm font-bold text-slate-400 truncate';
cardSyncStatus.textContent = 'En attente';
}
}
if (cardSyncTime) {
cardSyncTime.textContent = st.last_sync_at ? `Dernière synchro : ${st.last_sync_at}` : 'Dernière synchro : Jamais';
}
// Services Système (Diagnostic)
const svcKeepalived = document.getElementById('cluster-svc-keepalived');
if (svcKeepalived) {
if (!st.keepalived_installed) {
svcKeepalived.className = 'font-mono text-amber-400 font-bold';
svcKeepalived.textContent = '⚠️ Non installé';
} else if (st.keepalived_running) {
svcKeepalived.className = 'font-mono text-emerald-400 font-bold';
svcKeepalived.textContent = '🟢 Actif (En cours)';
} else {
svcKeepalived.className = 'font-mono text-slate-400';
svcKeepalived.textContent = '⚪ Arrêté';
}
}
const svcAsterisk = document.getElementById('cluster-svc-asterisk');
if (svcAsterisk) {
if (st.asterisk_healthy) {
svcAsterisk.className = 'font-mono text-emerald-400 font-bold';
svcAsterisk.textContent = '🟢 En ligne (Ping Pong OK)';
} else if (st.asterisk_running) {
svcAsterisk.className = 'font-mono text-amber-400 font-bold';
svcAsterisk.textContent = '🟡 En cours (Ping en attente)';
} else {
svcAsterisk.className = 'font-mono text-red-400 font-bold';
svcAsterisk.textContent = '🔴 Arrêté';
}
}
const svcPeer = document.getElementById('cluster-svc-peer');
if (svcPeer) {
if (!st.peer_ip) {
svcPeer.textContent = 'Non configuré';
svcPeer.className = 'font-mono text-slate-500';
} else if (st.peer_reachable) {
svcPeer.textContent = `🟢 Joignable (${st.peer_ping_ms !== null ? st.peer_ping_ms.toFixed(1) + 'ms' : 'OK'})`;
svcPeer.className = 'font-mono text-emerald-400 font-bold';
} else {
svcPeer.textContent = '🔴 Injoignable (Timeout)';
svcPeer.className = 'font-mono text-red-400 font-bold';
}
}
// Journal de logs
const logsContainer = document.getElementById('cluster-logs-container');
if (logsContainer && st.recent_logs) {
if (st.recent_logs.length > 0) {
logsContainer.innerHTML = st.recent_logs.map(line => {
let color = 'text-slate-300';
if (line.includes('MASTER')) color = 'text-emerald-300 font-bold';
if (line.includes('BACKUP') || line.includes('STANDBY')) color = 'text-blue-300';
if (line.includes('FAULT') || line.includes('ALERTE')) color = 'text-red-400 font-bold';
return `<div class="${color}">${escapeHtml(line)}</div>`;
}).join('');
} else {
logsContainer.innerHTML = '<p class="text-slate-500">Aucun événement enregistré.</p>';
}
}
} catch (e) {
console.error("Erreur lors du rafraîchissement du cluster :", e);
}
}
async function saveClusterSettings(e) {
e.preventDefault();
const form = e.target;
const payload = {
enabled: form.elements['enabled'].checked ? 1 : 0,
role: form.elements['role'].value,
interface: form.elements['interface'].value,
virtual_ip: form.elements['virtual_ip'].value.trim(),
virtual_netmask: form.elements['virtual_netmask'].value.trim() || '24',
peer_ip: form.elements['peer_ip'].value.trim(),
peer_port: parseInt(form.elements['peer_port'] ? form.elements['peer_port'].value : 8443, 10) || 8443,
cluster_secret: form.elements['cluster_secret'].value.trim(),
vrid: parseInt(form.elements['vrid'].value, 10) || 51,
blf_expiry: parseInt(form.elements['blf_expiry'] ? form.elements['blf_expiry'].value : 120, 10) || 120,
auto_sync: form.elements['auto_sync'].checked ? 1 : 0
};
if (payload.enabled && !payload.virtual_ip) {
showToast("L'adresse IP Virtuelle Flottante (VIP) est requise pour activer le cluster.", 'error');
return;
}
try {
const res = await apiCall('cluster_save', payload);
showToast(res.message || "Paramètres du cluster enregistrés.", 'success');
refreshClusterStatus();
} catch (err) {
showToast(err.message || "Erreur lors de l'enregistrement du cluster.", 'error');
}
}
async function triggerClusterSyncNow() {
if (!currentClusterData || !currentClusterData.peer_ip) {
showToast("Veuillez d'abord renseigner et enregistrer l'adresse IP du Nœud Pair (Peer IP).", 'warning');
return;
}
showToast("Déclenchement de la réplication vers le pair...", 'info');
try {
const res = await apiCall('cluster_sync_now', {});
showToast(res.message || "Synchronisation réussie avec le nœud pair !", 'success');
refreshClusterStatus();
} catch (e) {
showToast(e.message || "Erreur lors de la synchronisation.", 'error');
}
}
async function triggerClusterSwitchover() {
if (!currentClusterData || !currentClusterData.enabled) {
showToast("Le cluster doit être activé pour effectuer une bascule.", 'warning');
return;
}
const currentRole = currentClusterData.configured_role || 'master';
const targetRole = (currentRole === 'master') ? 'STANDBY' : 'MASTER';
if (!confirm(`Confirmez-vous la bascule manuelle de rôle (Switchover) vers l'état ${targetRole} ?\nL'adresse IP flottante va être transférée.`)) {
return;
}
try {
const res = await apiCall('cluster_switchover', {});
showToast(res.message || `Bascule effectuée. Nouveau rôle : ${targetRole}`, 'success');
refreshClusterStatus();
} catch (e) {
showToast(e.message || "Erreur lors de la bascule.", 'error');
}
}
function regenerateClusterSecret() {
const secretInput = document.getElementById('cluster-secret');
if (!secretInput) return;
const array = new Uint8Array(16);
window.crypto.getRandomValues(array);
const newSecret = Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('');
secretInput.value = newSecret;
showToast("Nouveau jeton généré. N'oubliez pas d'enregistrer et de copier cette même clé sur le second serveur !", 'info');
}
function copyClusterSecret() {
const secretInput = document.getElementById('cluster-secret');
if (!secretInput || !secretInput.value) return;
navigator.clipboard.writeText(secretInput.value).then(() => {
showToast("Jeton secret copié dans le presse-papier !", 'success');
}).catch(() => {
secretInput.select();
document.execCommand('copy');
showToast("Jeton secret copié !", 'success');
});
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 795 KiB

+565 -19
View File
@@ -4,6 +4,16 @@ declare(strict_types=1);
require_once __DIR__ . '/../src/Database.php';
require_once __DIR__ . '/../src/Auth.php';
// Autoloader PSR-4 automatique pour toutes les classes du namespace App
spl_autoload_register(function (string $class): void {
if (str_starts_with($class, 'App\\')) {
$file = __DIR__ . '/../src/' . str_replace('\\', '/', substr($class, 4)) . '.php';
if (file_exists($file)) {
require_once $file;
}
}
});
use App\Database;
use App\Auth;
@@ -18,7 +28,12 @@ $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.8';
$appVersion = $appVerData['version'] ?? '2.0.0';
$appCodename = $appVerData['codename'] ?? 'Pulsar';
// Détection de l'environnement de développement : présence de .dev local ou branche git dev
$isDevEnv = file_exists(__DIR__ . '/../.dev')
|| (trim((string)@shell_exec('git -C ' . escapeshellarg(__DIR__) . ' rev-parse --abbrev-ref HEAD 2>/dev/null')) === 'dev');
?>
<!DOCTYPE html>
<html lang="fr" class="h-full">
@@ -26,7 +41,9 @@ $appVersion = $appVerData['version'] ?? '1.4.8';
<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><?= $isDevEnv ? '🛠️ [DEV] ' : '' ?>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 +174,16 @@ $appVersion = $appVerData['version'] ?? '1.4.8';
<!-- 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 flex items-center gap-2">
NexusPBX
<?php if ($isDevEnv): ?>
<span class="px-2 py-0.5 text-xs font-mono font-extrabold tracking-wider rounded bg-amber-500/20 text-amber-300 border border-amber-500/40">DEV</span>
<?php endif; ?>
</h1>
<p class="text-xs text-slate-300">Supervision & Téléphonie IP • Powered by Asterisk</p>
</div>
</div>
@@ -207,19 +225,18 @@ $appVersion = $appVerData['version'] ?? '1.4.8';
<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">
Nexus<span class="text-indigo-400">PBX</span>
</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>
<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 class="hidden sm:inline font-sans font-normal opacity-80">« <?= htmlspecialchars($appCodename) ?> »</span></span>
<?php if ($isDevEnv): ?>
<span class="px-1.5 py-0.5 text-[10px] font-mono font-black tracking-wider rounded bg-amber-500/25 text-amber-300 border border-amber-500/50 shadow-xs shadow-amber-500/20 animate-pulse">DEV</span>
<?php endif; ?>
</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 +275,18 @@ $appVersion = $appVerData['version'] ?? '1.4.8';
<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 -->
@@ -436,6 +465,11 @@ $appVersion = $appVerData['version'] ?? '1.4.8';
<span class="truncate">• Pare-feu & Fail2ban</span>
<span id="nav-badge-firewall" class="ml-2 shrink-0 px-1.5 py-0.2 text-[10px] rounded-full bg-anthracite-800 text-emerald-300 border border-anthracite-700 font-bold">0</span>
</button>
<button type="button" data-tab="cluster" onclick="switchTab('cluster')"
class="tab-btn w-full flex items-center justify-between px-3 py-1.5 rounded-lg text-xs font-medium text-slate-300 hover:bg-anthracite-800 hover:text-white transition text-left">
<span class="truncate">• Haute Disponibilité (HA)</span>
<span id="nav-badge-cluster" class="ml-2 shrink-0 px-1.5 py-0.2 text-[10px] rounded-full bg-anthracite-800 text-cyan-300 border border-anthracite-700 font-bold">HA</span>
</button>
</div>
</div>
</div>
@@ -488,6 +522,13 @@ $appVersion = $appVerData['version'] ?? '1.4.8';
<!-- Boutons d'action globaux -->
<div class="flex items-center gap-2.5">
<?php if ($isDevEnv): ?>
<div class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-bold bg-amber-500/15 text-amber-300 border border-amber-500/30">
<span class="w-2 h-2 rounded-full bg-amber-400 animate-pulse"></span>
<span class="hidden md:inline font-mono">SERVEUR</span> DEV
</div>
<?php endif; ?>
<!-- Badge Notification Mise à Jour Disponible -->
<button id="header-update-badge" type="button" onclick="openUpdatePanel()" style="display:none;"
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-bold bg-amber-500/20 hover:bg-amber-500/30 text-amber-300 border border-amber-500/40 shadow-sm transition animate-pulse" title="Une mise à jour NexusPBX est disponible ! Cliquez pour voir les détails.">
@@ -980,6 +1021,158 @@ $appVersion = $appVerData['version'] ?? '1.4.8';
</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 -->
<!-- ========================================================= -->
@@ -1587,7 +1780,7 @@ $appVersion = $appVerData['version'] ?? '1.4.8';
<span id="update-dot" class="relative inline-flex rounded-full h-3 w-3 bg-emerald-500"></span>
</span>
<h3 class="text-base font-bold text-white">Mises à Jour du Système NexusPBX</h3>
<span id="system-version-tag" class="px-2 py-0.5 text-xs font-mono font-bold rounded-lg bg-indigo-500/20 text-indigo-300 border border-indigo-500/30">v<?= htmlspecialchars($appVersion) ?></span>
<span id="system-version-tag" class="px-2 py-0.5 text-xs font-mono font-bold rounded-lg bg-indigo-500/20 text-indigo-300 border border-indigo-500/30">v<?= htmlspecialchars($appVersion) ?> — <?= htmlspecialchars($appCodename) ?></span>
</div>
<p class="text-xs text-slate-300 mt-1">Vérifiez les nouvelles versions officielles et mettez à jour votre PBX en 1 clic sans coupure de service.</p>
</div>
@@ -2362,7 +2555,7 @@ $appVersion = $appVerData['version'] ?? '1.4.8';
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 pt-2">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 pt-2">
<div>
<label class="block text-xs font-semibold text-slate-200 mb-1">RTP Keepalive (secondes)</label>
<input type="number" id="sip-rtp-keepalive" name="rtp_keepalive" value="15" min="0" max="120"
@@ -2375,6 +2568,12 @@ $appVersion = $appVerData['version'] ?? '1.4.8';
class="w-full px-3 py-2 rounded-xl bg-anthracite-950 border border-anthracite-700 text-xs text-white font-mono focus:ring-2 focus:ring-emerald-500 focus:outline-none">
<p class="text-[11px] text-slate-400 mt-1">Durée avant coupure si aucun flux voix n'est reçu (raccroché distant brutal).</p>
</div>
<div>
<label class="block text-xs font-semibold text-slate-200 mb-1">RTP Timeout Mise en attente</label>
<input type="number" id="sip-rtp-timeout-hold" name="rtp_timeout_hold" value="0" min="0" max="3600"
class="w-full px-3 py-2 rounded-xl bg-anthracite-950 border border-anthracite-700 text-xs text-white font-mono focus:ring-2 focus:ring-emerald-500 focus:outline-none">
<p class="text-[11px] text-slate-400 mt-1">Délai max d'un appel en attente sans flux RTP (0 = illimité / désactivé).</p>
</div>
</div>
</div>
@@ -2578,6 +2777,265 @@ $appVersion = $appVerData['version'] ?? '1.4.8';
</div>
</section>
<!-- ========================================================================= -->
<!-- ONGLET 16 : HAUTE DISPONIBILITÉ & CLUSTER HA (ACTIF / PASSIF KEEPALIVED) -->
<!-- ========================================================================= -->
<section id="tab-cluster" class="tab-panel hidden space-y-6">
<!-- EN-TÊTE DE LA SECTION -->
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 bg-anthracite-800 p-6 rounded-2xl border border-anthracite-700">
<div>
<div class="flex items-center gap-2 mb-1">
<span class="px-2.5 py-0.5 rounded-full text-xs font-bold bg-cyan-500/20 text-cyan-300 border border-cyan-500/30">CLUSTER HA</span>
<span id="cluster-header-state-badge" class="px-2.5 py-0.5 rounded-full text-xs font-bold bg-slate-700 text-slate-300">DÉSACTIVÉ</span>
</div>
<h2 class="text-xl font-black text-white tracking-wide">Haute Disponibilité (Cluster HA Actif / Passif)</h2>
<p class="text-xs text-slate-300 mt-1">Bascule automatique en &lt; 3 secondes par IP Virtuelle Flottante (VRRP / Keepalived) et synchronisation temps réel.</p>
</div>
<div class="flex items-center gap-2">
<button type="button" onclick="refreshClusterStatus()"
class="px-3.5 py-2 rounded-xl text-xs font-bold text-slate-300 bg-anthracite-900 border border-anthracite-700 hover:text-white hover:border-slate-500 transition flex items-center gap-2">
<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="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="triggerClusterSwitchover()" id="btn-cluster-switchover"
class="px-3.5 py-2 rounded-xl text-xs font-bold text-amber-300 bg-amber-500/10 border border-amber-500/30 hover:bg-amber-500/20 transition flex items-center gap-2">
<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="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4"/></svg>
<span>Bascule Manuelle (Switchover)</span>
</button>
</div>
</div>
<!-- CARTES STATISTIQUES / ÉTAT EN TEMPS RÉEL -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<!-- CARTE 1 : ÉTAT NŒUD LOCAL -->
<div class="p-4 rounded-xl bg-anthracite-800 border border-anthracite-700 space-y-2">
<div class="flex items-center justify-between text-xs text-slate-400">
<span>Nœud Local</span>
<span id="cluster-card-role-badge" class="px-2 py-0.5 rounded text-[10px] font-bold bg-slate-700 text-slate-300">MASTER</span>
</div>
<div class="flex items-baseline justify-between">
<span id="cluster-card-state" class="text-lg font-bold text-white tracking-wide">STANDALONE</span>
<span id="cluster-card-vip-indicator" class="text-[10px] font-mono px-1.5 py-0.5 rounded bg-anthracite-900 text-slate-400">VIP: Inactive</span>
</div>
<p id="cluster-card-iface" class="text-[11px] text-slate-400 font-mono">Interface : ens18</p>
</div>
<!-- CARTE 2 : ADRESSE IP VIRTUELLE (VIP) -->
<div class="p-4 rounded-xl bg-anthracite-800 border border-anthracite-700 space-y-2">
<div class="flex items-center justify-between text-xs text-slate-400">
<span>IP Virtuelle (VIP)</span>
<span class="text-[10px] font-bold text-cyan-400">VRRP ID: <span id="cluster-card-vrid">51</span></span>
</div>
<div class="text-lg font-bold font-mono text-cyan-300 tracking-wide" id="cluster-card-vip">
Non configurée
</div>
<p class="text-[11px] text-slate-400">Point de connexion des Postes &amp; Trunks</p>
</div>
<!-- CARTE 3 : NŒUD DISTANT (PEER) -->
<div class="p-4 rounded-xl bg-anthracite-800 border border-anthracite-700 space-y-2">
<div class="flex items-center justify-between text-xs text-slate-400">
<span>Nœud Distant (Pair)</span>
<span id="cluster-card-peer-status" class="px-2 py-0.5 rounded text-[10px] font-bold bg-slate-700 text-slate-300">INCONNU</span>
</div>
<div class="text-base font-bold font-mono text-white tracking-wide truncate" id="cluster-card-peer-ip">
Non configuré
</div>
<p id="cluster-card-peer-ping" class="text-[11px] text-slate-400 font-mono">Latence ping : —</p>
</div>
<!-- CARTE 4 : RÉPLICATION DES DONNÉES -->
<div class="p-4 rounded-xl bg-anthracite-800 border border-anthracite-700 space-y-2">
<div class="flex items-center justify-between text-xs text-slate-400">
<span>Synchronisation</span>
<button type="button" onclick="triggerClusterSyncNow()" class="text-[10px] text-indigo-400 hover:text-indigo-300 font-bold underline">Sync Now</button>
</div>
<div class="text-sm font-bold text-white truncate" id="cluster-card-sync-status">
Jamais synchronisé
</div>
<p id="cluster-card-sync-time" class="text-[11px] text-slate-400 font-mono">Dernier échange : —</p>
</div>
</div>
<!-- CONTENU PRINCIPAL : FORMULAIRE & ACTIONS -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- FORMULAIRE DE CONFIGURATION DU CLUSTER (2/3) -->
<div class="lg:col-span-2 bg-anthracite-800 p-6 rounded-2xl border border-anthracite-700 space-y-5">
<h3 class="text-sm font-bold text-white uppercase tracking-wider flex items-center gap-2">
<svg class="w-4 h-4 text-cyan-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"/></svg>
<span>Paramétrage de la Haute Disponibilité (Keepalived VRRP)</span>
</h3>
<form id="form-cluster-settings" onsubmit="saveClusterSettings(event)" class="space-y-4">
<!-- SWITCH ACTIVATION -->
<div class="flex items-center justify-between p-3.5 rounded-xl bg-anthracite-900/80 border border-anthracite-700">
<div>
<label for="cluster-enabled" class="text-sm font-bold text-white cursor-pointer">Activer le Cluster Haute Disponibilité</label>
<p class="text-xs text-slate-400">Active Keepalived pour surveiller Asterisk et partager l'IP flottante.</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" id="cluster-enabled" name="enabled" value="1" class="sr-only peer">
<div class="w-11 h-6 bg-anthracite-700 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-slate-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-cyan-500"></div>
</label>
</div>
<!-- RÔLE DU SERVEUR -->
<div class="space-y-1.5">
<label class="block text-xs font-bold text-slate-300 uppercase">Rôle initial de ce serveur</label>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<label class="flex items-center gap-3 p-3 rounded-xl border border-anthracite-700 bg-anthracite-900/60 cursor-pointer hover:border-slate-500 transition">
<input type="radio" name="role" value="master" class="text-cyan-500 focus:ring-cyan-400" checked>
<div>
<span class="text-xs font-bold text-white">Nœud 1 : Master (Primaire)</span>
<p class="text-[10px] text-slate-400">Priorité VRRP 100 • Porte la VIP au démarrage.</p>
</div>
</label>
<label class="flex items-center gap-3 p-3 rounded-xl border border-anthracite-700 bg-anthracite-900/60 cursor-pointer hover:border-slate-500 transition">
<input type="radio" name="role" value="backup" class="text-cyan-500 focus:ring-cyan-400">
<div>
<span class="text-xs font-bold text-white">Nœud 2 : Standby (Secondaire)</span>
<p class="text-[10px] text-slate-400">Priorité VRRP 90 • Prend le relais si le Master tombe.</p>
</div>
</label>
</div>
</div>
<!-- INTERFACE RÉSEAU & IP VIRTUELLE -->
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div class="sm:col-span-1">
<label class="block text-xs font-bold text-slate-300 uppercase mb-1">Carte Réseau</label>
<select id="cluster-interface" name="interface" required
class="w-full px-3 py-2 rounded-xl bg-anthracite-900 border border-anthracite-700 text-sm text-white focus:outline-none focus:border-cyan-400 font-mono">
<option value="eth0">eth0</option>
</select>
</div>
<div class="sm:col-span-2">
<label class="block text-xs font-bold text-slate-300 uppercase mb-1">IP Virtuelle Flottante (VIP)</label>
<div class="flex gap-2">
<input type="text" id="cluster-virtual-ip" name="virtual_ip" placeholder="ex: 10.10.0.100"
class="flex-1 px-3 py-2 rounded-xl bg-anthracite-900 border border-anthracite-700 text-sm text-white focus:outline-none focus:border-cyan-400 font-mono">
<span class="flex items-center text-slate-400 font-mono text-sm">/</span>
<input type="text" id="cluster-virtual-netmask" name="virtual_netmask" value="24" placeholder="24"
class="w-16 px-2 py-2 rounded-xl bg-anthracite-900 border border-anthracite-700 text-sm text-white text-center focus:outline-none focus:border-cyan-400 font-mono">
</div>
</div>
</div>
<!-- IP DU PAIR & JETON SECRET -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label class="block text-xs font-bold text-slate-300 uppercase mb-1">IP & Port d'Administration du Pair</label>
<div class="flex gap-2">
<input type="text" id="cluster-peer-ip" name="peer_ip" placeholder="ex: 10.10.0.5"
class="flex-1 px-3 py-2 rounded-xl bg-anthracite-900 border border-anthracite-700 text-sm text-white focus:outline-none focus:border-cyan-400 font-mono">
<span class="flex items-center text-slate-400 font-mono text-sm">:</span>
<input type="number" id="cluster-peer-port" name="peer_port" value="8443" min="1" max="65535" placeholder="8443"
class="w-24 px-3 py-2 rounded-xl bg-anthracite-900 border border-anthracite-700 text-sm text-white text-center focus:outline-none focus:border-cyan-400 font-mono" title="Port HTTPS d'administration du serveur distant (ex: 8443 ou 2356)">
</div>
</div>
<div>
<label class="block text-xs font-bold text-slate-300 uppercase mb-1">Jeton de Sécurité Cluster (Secret)</label>
<div class="flex gap-1.5">
<input type="text" id="cluster-secret" name="cluster_secret" required
class="flex-1 px-3 py-2 rounded-xl bg-anthracite-900 border border-anthracite-700 text-xs text-white font-mono focus:outline-none focus:border-cyan-400">
<button type="button" onclick="regenerateClusterSecret()" title="Générer un nouveau token"
class="px-2.5 py-2 rounded-xl bg-anthracite-900 border border-anthracite-700 text-slate-300 hover:text-white transition">🎲</button>
<button type="button" onclick="copyClusterSecret()" title="Copier le token"
class="px-2.5 py-2 rounded-xl bg-anthracite-900 border border-anthracite-700 text-slate-300 hover:text-white transition">📋</button>
</div>
</div>
</div>
<!-- DÉLAI D'EXPIRATION / RÉENREGISTREMENT POSTES (REGISTER HA) -->
<div id="cluster-blf-expiry-container" class="hidden p-3.5 rounded-xl bg-anthracite-900/80 border border-cyan-500/30 space-y-1">
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div class="space-y-1">
<div class="flex items-center gap-2 mb-0.5">
<label for="cluster-blf-expiry" class="text-xs font-bold text-white cursor-pointer">Intervalle de réenregistrement des Postes (REGISTER)</label>
<span class="px-2 py-0.5 rounded text-[10px] font-bold bg-cyan-500/20 text-cyan-300 border border-cyan-500/30">Recommandé HA : 120s</span>
</div>
<p class="text-[11px] text-slate-400 leading-relaxed">
Plafond d'expiration imposé aux téléphones pour le renouvellement de leur enregistrement SIP (<code class="text-cyan-300 font-mono text-[10px]">REGISTER</code>). En cas de bascule HA, ce délai force les postes à se réenregistrer rapidement sur le serveur actif (ex. toutes les 60s pour un plafond de 120s) afin de rétablir les appels entrants et sortants sans attendre (par défaut 1800s sans HA).
</p>
<p class="text-[10px] text-amber-400/90 leading-normal flex items-start gap-1.5 pt-0.5">
<span class="text-xs">ℹ️</span>
<span><strong>Voyants BLF (<code class="text-amber-300 font-mono text-[10px]">SUBSCRIBE</code>) :</strong> Ce paramètre ne gère pas les touches BLF. La fréquence de rafraîchissement des touches de supervision est décidée directement par le téléphone lui-même (ex. paramètre Yealink <code class="text-amber-300 font-mono text-[10px]">account.X.blf.subscribe_period = 120</code>).</span>
</p>
</div>
<div class="flex items-center gap-2 shrink-0">
<input type="number" id="cluster-blf-expiry" name="blf_expiry" value="120" min="30" max="3600"
class="w-24 px-3 py-2 rounded-xl bg-anthracite-900 border border-anthracite-700 text-sm text-white text-center font-mono focus:outline-none focus:border-cyan-400">
<span class="text-xs text-slate-400 font-mono">sec</span>
</div>
</div>
</div>
<!-- OPTIONS AVANCÉES (VRID, AUTO-SYNC) -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 pt-2 border-t border-anthracite-700/60">
<div class="flex items-center gap-2">
<input type="checkbox" id="cluster-auto-sync" name="auto_sync" value="1" checked
class="rounded bg-anthracite-900 border-anthracite-700 text-cyan-500 focus:ring-cyan-400">
<label for="cluster-auto-sync" class="text-xs text-slate-300 cursor-pointer">Synchroniser automatiquement la base et les sons vers le pair</label>
</div>
<div class="flex items-center justify-end gap-2 text-xs text-slate-400 font-mono">
<span>Virtual Router ID (VRID) :</span>
<input type="number" id="cluster-vrid" name="vrid" value="51" min="1" max="255"
class="w-16 px-2 py-1 rounded bg-anthracite-900 border border-anthracite-700 text-white text-center text-xs">
</div>
</div>
<div class="pt-2 flex justify-end">
<button type="submit"
class="px-5 py-2.5 rounded-xl font-bold text-xs bg-cyan-500 hover:bg-cyan-400 text-slate-950 transition flex items-center gap-2 shadow-lg shadow-cyan-500/20">
<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="M5 13l4 4L19 7"/></svg>
<span>Enregistrer &amp; Déployer le Cluster Keepalived</span>
</button>
</div>
</form>
</div>
<!-- PANNEAU DROIT : LOGS & DIAGNOSTIC EN DIRECT (1/3) -->
<div class="bg-anthracite-800 p-6 rounded-2xl border border-anthracite-700 space-y-4 flex flex-col justify-between">
<div class="space-y-4">
<h3 class="text-sm font-bold text-white uppercase tracking-wider flex items-center gap-2">
<svg class="w-4 h-4 text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
<span>Services &amp; Santé Système</span>
</h3>
<div class="space-y-2 text-xs">
<div class="flex items-center justify-between p-2.5 rounded-xl bg-anthracite-900 border border-anthracite-700/80">
<span class="text-slate-300">Démon Keepalived</span>
<span id="cluster-svc-keepalived" class="font-mono text-slate-400">Vérification...</span>
</div>
<div class="flex items-center justify-between p-2.5 rounded-xl bg-anthracite-900 border border-anthracite-700/80">
<span class="text-slate-300">Processus Asterisk</span>
<span id="cluster-svc-asterisk" class="font-mono text-slate-400">Vérification...</span>
</div>
<div class="flex items-center justify-between p-2.5 rounded-xl bg-anthracite-900 border border-anthracite-700/80">
<span class="text-slate-300">Ping Nœud Pair</span>
<span id="cluster-svc-peer" class="font-mono text-slate-400">—</span>
</div>
</div>
<div class="pt-2">
<h4 class="text-xs font-bold text-slate-300 uppercase mb-1.5 flex items-center justify-between">
<span>Journal d'Événements Cluster</span>
<span class="text-[10px] text-slate-500 font-mono">/var/log/nexus_cluster.log</span>
</h4>
<div id="cluster-logs-container"
class="h-44 overflow-y-auto p-3 rounded-xl bg-anthracite-950 border border-anthracite-700/80 font-mono text-[11px] text-slate-300 space-y-1">
<p class="text-slate-500">Aucun événement enregistré.</p>
</div>
</div>
</div>
<div class="p-3 rounded-xl bg-indigo-950/40 border border-indigo-500/30 text-[11px] text-indigo-300">
💡 <strong>Conseil d'Architecture :</strong> Vos téléphones IP (Yealink, Groundwire) et vos Trunks SIP doivent pointer sur l'adresse <strong>IP Virtuelle (VIP)</strong>.
</div>
</div>
</div>
</section>
</main>
</div>
</div>
@@ -3791,6 +4249,10 @@ $appVersion = $appVerData['version'] ?? '1.4.8';
<input type="checkbox" name="trust_id_inbound" value="1" checked class="rounded bg-anthracite-900 border-anthracite-600">
<span>Accepter l'identité entrante PAI/RPID (<code class="text-indigo-300">trust_id_inbound = yes</code>)</span>
</label>
<label class="flex items-center gap-2 text-xs text-slate-200 cursor-pointer">
<input type="checkbox" name="trust_id_outbound" value="1" checked class="rounded bg-anthracite-900 border-anthracite-600">
<span>Transmettre l'identité sortante Caller ID (<code class="text-indigo-300">trust_id_outbound = yes</code>)</span>
</label>
</div>
</div>
</div>
@@ -4912,6 +5374,90 @@ $appVersion = $appVerData['version'] ?? '1.4.8';
</div>
</div>
<!-- ===================================================================== -->
<!-- MODAL : CONFIRMATION DE REDÉMARRAGE POSTE SIP -->
<!-- ===================================================================== -->
<div id="modal-confirm-reboot-extension" class="fixed inset-0 z-50 hidden items-center justify-center bg-black/80 backdrop-blur-sm p-3 sm:p-4 overflow-y-auto">
<div class="bg-anthracite-900 border border-amber-500/40 rounded-2xl w-full max-w-lg shadow-2xl overflow-hidden my-6 animate-fadeIn">
<!-- Entête -->
<div class="px-6 py-5 border-b border-anthracite-800 bg-gradient-to-r from-amber-950/40 via-anthracite-900 to-anthracite-900 flex items-start justify-between">
<div class="flex items-center gap-3.5">
<div class="w-11 h-11 rounded-xl bg-amber-500/15 border border-amber-500/30 text-amber-400 flex items-center justify-center shrink-0 shadow-lg shadow-amber-950/50">
<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="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>
</div>
<div>
<div class="flex items-center gap-2">
<h3 class="text-base font-bold text-white">Redémarrer le Téléphone IP</h3>
<span class="px-2 py-0.5 rounded text-[10px] font-bold tracking-wide uppercase bg-amber-500/20 text-amber-300 border border-amber-500/30">SIP NOTIFY</span>
</div>
<p class="text-xs text-slate-400 mt-0.5">
Poste <span id="reboot-modal-ext-num" class="font-mono font-bold text-indigo-300"></span> — <span id="reboot-modal-ext-name" class="font-medium text-slate-200"></span>
</p>
</div>
</div>
<button type="button" onclick="closeRebootModal()" class="text-slate-400 hover:text-white p-1.5 rounded-lg hover:bg-anthracite-800 transition">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
<!-- Corps -->
<div class="p-6 space-y-4">
<!-- Indicateur de vérification de l'état en direct -->
<div id="reboot-modal-checking" class="flex items-center gap-2.5 p-3 rounded-xl bg-anthracite-950 border border-anthracite-800 text-xs text-slate-400">
<svg class="w-4 h-4 text-indigo-400 animate-spin shrink-0" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z"></path></svg>
<span>Vérification des appels en cours sur ce combiné...</span>
</div>
<!-- ALERTE ROUGE : Appel / conversation en cours -->
<div id="reboot-modal-call-warning" class="hidden p-4 rounded-xl bg-rose-500/15 border-2 border-rose-500/60 space-y-3">
<div class="flex items-center gap-2 text-rose-400 font-bold text-sm">
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/></svg>
<span>ATTENTION : CONVERSATION EN COURS SUR CE POSTE !</span>
</div>
<p class="text-xs text-rose-200">
Ce combiné est actuellement <strong class="underline">en communication active</strong>.
</p>
<div id="reboot-modal-call-details" class="p-2.5 rounded-lg bg-black/50 border border-rose-500/40 text-xs font-mono text-rose-300"></div>
<div class="p-3 rounded-lg bg-rose-950/60 border border-rose-500/40 text-xs font-semibold text-rose-200 flex items-start gap-2">
<span class="text-base leading-none">⚠️</span>
<span>Si vous confirmez le redémarrage, <strong>la conversation téléphonique sera immédiatement coupée</strong> pour votre interlocuteur !</span>
</div>
</div>
<!-- Information normale (quand pas d'appel) -->
<div id="reboot-modal-normal-info" class="hidden space-y-3">
<div class="p-3.5 rounded-xl bg-anthracite-950 border border-anthracite-800 text-xs text-slate-300 space-y-2">
<div class="flex items-center gap-2 text-emerald-400 font-semibold">
<span class="w-2 h-2 rounded-full bg-emerald-400"></span>
<span>Aucune conversation en cours (poste libre)</span>
</div>
<p class="text-slate-400">
Un ordre SIP NOTIFY standard (<code class="text-amber-300 font-mono">check-sync;reboot=true</code>) va être envoyé au téléphone.
</p>
</div>
<div class="p-3.5 rounded-xl bg-amber-500/10 border border-amber-500/20 text-xs text-amber-200 space-y-1">
<div class="font-bold">Effet du redémarrage :</div>
<p class="text-slate-300">Le téléphone va redémarrer, recharger son provisioning et synchroniser ses touches BLF (interruption d'environ 30 à 45 secondes).</p>
</div>
</div>
</div>
<!-- Boutons d'action -->
<div class="px-6 py-4 border-t border-anthracite-800 bg-anthracite-950/80 flex items-center justify-end gap-3">
<button type="button" onclick="closeRebootModal()"
class="px-4 py-2.5 rounded-xl text-xs font-semibold bg-anthracite-800 hover:bg-anthracite-750 text-slate-300 border border-anthracite-700 transition">
Annuler
</button>
<button type="button" id="btn-confirm-reboot-action" onclick="confirmRebootExtension()"
class="px-5 py-2.5 rounded-xl text-xs font-bold bg-amber-600 hover:bg-amber-500 text-white shadow-lg shadow-amber-600/30 transition flex items-center gap-2">
<span id="reboot-confirm-btn-label">Confirmer le redémarrage</span>
</button>
</div>
</div>
</div>
<script src="/assets/app.js?v=<?= filemtime(__DIR__ . '/assets/app.js') ?>"></script>
</body>
</html>
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# ==============================================================================
# NexusPBX HA - Script de surveillance de santé d'Asterisk pour Keepalived VRRP
# ==============================================================================
set -euo pipefail
export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:$PATH"
# 1. Vérifie si le binaire et le démon asterisk tournent
if ! pidof asterisk >/dev/null 2>&1; then
exit 1
fi
# 2. Teste si Asterisk répond aux requêtes CLI (core show uptime)
RESP=$(/usr/sbin/asterisk -rx "core show uptime" 2>/dev/null || true)
if echo "$RESP" | grep -qi "uptime"; then
exit 0
fi
# 3. Tentative alternative via core show version
if /usr/sbin/asterisk -rx "core show version" >/dev/null 2>&1; then
exit 0
fi
exit 1
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env bash
# ==============================================================================
# NexusPBX HA - Notification Keepalived : Transition vers l'état BACKUP / STANDBY
# ==============================================================================
set -euo pipefail
export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:$PATH"
LOG_FILE="/var/log/nexus_cluster.log"
TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')
echo "[$TIMESTAMP] [CLUSTER] <<< Transition vers l'état BACKUP / STANDBY (En veille) >>>" >> "$LOG_FILE"
# 1. Mise à jour de l'état en base locale
DB_PATH="/var/www/nexus-pbx/storage/database.sqlite"
if [ -f "$DB_PATH" ]; then
sqlite3 "$DB_PATH" "UPDATE cluster_settings SET last_sync_status='standby_idle', updated_at=CURRENT_TIMESTAMP WHERE id=1;" 2>/dev/null || true
chown www-data:asterisk "$DB_PATH" "${DB_PATH}-wal" "${DB_PATH}-shm" 2>/dev/null || true
chmod 660 "$DB_PATH" "${DB_PATH}-wal" "${DB_PATH}-shm" 2>/dev/null || true
fi
# 2. Raccrochage immédiat des canaux orphelins et désenregistrement des Trunks PJSIP
# Un nœud en Standby / Backup ne doit avoir aucun canal actif afin de libérer les BLF et éviter les conflits
if pidof asterisk >/dev/null 2>&1; then
/usr/sbin/asterisk -rx "channel request hangup all" >> "$LOG_FILE" 2>&1 || true
/usr/sbin/asterisk -rx "pjsip send unregister *" >> "$LOG_FILE" 2>&1 || true
for reg in $(/usr/sbin/asterisk -rx "pjsip show registrations" 2>/dev/null | grep -o '[a-zA-Z0-9_\-]*-reg' | sort -u); do
/usr/sbin/asterisk -rx "pjsip send unregister $reg" >> "$LOG_FILE" 2>&1 || true
done
/usr/sbin/asterisk -rx "dialplan reload" >> "$LOG_FILE" 2>&1 || true
fi
# 3. Isolation SIP stricte du nœud Standby / Backup via iptables (Architecture Télécom HA)
# Un nœud en veille ne doit JAMAIS répondre aux requêtes SIP des téléphones.
# Tout paquet SIP arrivant sur le Slave est immédiatement rejeté (ICMP port unreachable),
# ce qui force les téléphones (Yealink T46S, T54W, Fanvil, Snom...) à clore immédiatement
# leurs anciens dialogues orphelins et à réémettre leurs SUBSCRIBE BLF et REGISTER vers la VIP active.
iptables -C INPUT -i lo -j ACCEPT 2>/dev/null || iptables -I INPUT -i lo -j ACCEPT
iptables -C INPUT -p udp -m multiport --dports 5060,5061 -j REJECT --reject-with icmp-port-unreachable 2>/dev/null || \
iptables -A INPUT -p udp -m multiport --dports 5060,5061 -j REJECT --reject-with icmp-port-unreachable
iptables -C INPUT -p tcp -m multiport --dports 5060,5061 -j REJECT --reject-with tcp-reset 2>/dev/null || \
iptables -A INPUT -p tcp -m multiport --dports 5060,5061 -j REJECT --reject-with tcp-reset
echo "[$TIMESTAMP] [CLUSTER] Mode Standby actif (Ports SIP isolés). En écoute des battements de cœur VRRP." >> "$LOG_FILE"
exit 0
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# ==============================================================================
# NexusPBX HA - Notification Keepalived : Transition vers l'état FAULT / ERREUR
# ==============================================================================
set -euo pipefail
export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:$PATH"
LOG_FILE="/var/log/nexus_cluster.log"
TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')
echo "[$TIMESTAMP] [CLUSTER] [ALERTE] ÉTAT FAULT DÉTECTÉ sur le nœud !" >> "$LOG_FILE"
DB_PATH="/var/www/nexus-pbx/storage/database.sqlite"
if [ -f "$DB_PATH" ]; then
sqlite3 "$DB_PATH" "UPDATE cluster_settings SET last_sync_status='fault', updated_at=CURRENT_TIMESTAMP WHERE id=1;" 2>/dev/null || true
fi
# Isolation des ports SIP en cas d'anomalie
iptables -C INPUT -i lo -j ACCEPT 2>/dev/null || iptables -I INPUT -i lo -j ACCEPT
iptables -C INPUT -p udp -m multiport --dports 5060,5061 -j REJECT --reject-with icmp-port-unreachable 2>/dev/null || \
iptables -A INPUT -p udp -m multiport --dports 5060,5061 -j REJECT --reject-with icmp-port-unreachable
iptables -C INPUT -p tcp -m multiport --dports 5060,5061 -j REJECT --reject-with tcp-reset 2>/dev/null || \
iptables -A INPUT -p tcp -m multiport --dports 5060,5061 -j REJECT --reject-with tcp-reset
exit 0
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env bash
# ==============================================================================
# NexusPBX HA - Notification Keepalived : Transition vers l'état MASTER
# ==============================================================================
set -euo pipefail
export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:$PATH"
LOG_FILE="/var/log/nexus_cluster.log"
TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')
echo "[$TIMESTAMP] [CLUSTER] >>> Transition vers l'état MASTER (VIP active) <<<" >> "$LOG_FILE"
# 0. Réconciliation préalable au Failback : si le pair était actif et a des modifications plus récentes,
# récupérer sa réplication AVANT d'ouvrir les flux SIP et de servir le trafic !
if [ -f "/var/www/nexus-pbx/scripts/nexus_cluster_sync.php" ]; then
echo "[$TIMESTAMP] [CLUSTER] Vérification réconciliation failback avec le pair..." >> "$LOG_FILE"
php /var/www/nexus-pbx/scripts/nexus_cluster_sync.php --failback-check >> "$LOG_FILE" 2>&1 || true
fi
# 1. Ouverture immédiate des ports SIP entrants sur le nouveau MASTER (levée d'isolation)
iptables -D INPUT -p udp -m multiport --dports 5060,5061 -j REJECT --reject-with icmp-port-unreachable 2>/dev/null || true
iptables -D INPUT -p tcp -m multiport --dports 5060,5061 -j REJECT --reject-with tcp-reset 2>/dev/null || true
# 1. Mise à jour de l'état en base de données locale si SQLite disponible
DB_PATH="/var/www/nexus-pbx/storage/database.sqlite"
ASTDB_PATH="/var/lib/asterisk/astdb.sqlite3"
if [ -f "$DB_PATH" ]; then
sqlite3 "$DB_PATH" "UPDATE cluster_settings SET last_sync_status='master_active', updated_at=CURRENT_TIMESTAMP WHERE id=1;" 2>/dev/null || true
chown www-data:asterisk "$DB_PATH" "${DB_PATH}-wal" "${DB_PATH}-shm" 2>/dev/null || true
chmod 660 "$DB_PATH" "${DB_PATH}-wal" "${DB_PATH}-shm" 2>/dev/null || true
fi
# 2. Vérification et réparation d'intégrité de la base SQLite AstDB (contacts téléphones)
if [ -f "$ASTDB_PATH" ]; then
INTEGRITY=$(sqlite3 "$ASTDB_PATH" "PRAGMA integrity_check;" 2>/dev/null || echo "corrupt")
if [ "$INTEGRITY" != "ok" ]; then
echo "[$TIMESTAMP] [CLUSTER] ALERTE: astdb.sqlite3 corrompu ($INTEGRITY). Auto-réparation immédiate..." >> "$LOG_FILE"
mv -f "$ASTDB_PATH" "${ASTDB_PATH}.corrupt.$(date +%s)" 2>/dev/null || true
sqlite3 "$ASTDB_PATH" "CREATE TABLE astdb(key TEXT, value TEXT, PRIMARY KEY(key));" 2>/dev/null || true
chown asterisk:asterisk "$ASTDB_PATH" 2>/dev/null || true
chmod 664 "$ASTDB_PATH" 2>/dev/null || true
echo "[$TIMESTAMP] [CLUSTER] astdb.sqlite3 recréé proprement." >> "$LOG_FILE"
fi
fi
# 3. Purge préventive des canaux résiduels / fantômes et rechargement PJSIP/Dialplan
# Si le nœud a été mis en pause ou déconnecté, les canaux antérieurs sont morts.
# Leur raccrochage réinitialise immédiatement les hints à l'état Idle et éteint les BLF.
if pidof asterisk >/dev/null 2>&1; then
/usr/sbin/asterisk -rx "channel request hangup all" >> "$LOG_FILE" 2>&1 || true
/usr/sbin/asterisk -rx "database deltree subscription_persistence" >> "$LOG_FILE" 2>&1 || true
/usr/sbin/asterisk -rx "pjsip reload" >> "$LOG_FILE" 2>&1 || true
/usr/sbin/asterisk -rx "pjsip send register *" >> "$LOG_FILE" 2>&1 || true
for reg in $(/usr/sbin/asterisk -rx "pjsip show registrations" 2>/dev/null | grep -o '[a-zA-Z0-9_\-]*-reg' | sort -u); do
/usr/sbin/asterisk -rx "pjsip send register $reg" >> "$LOG_FILE" 2>&1 || true
done
/usr/sbin/asterisk -rx "dialplan reload" >> "$LOG_FILE" 2>&1 || true
fi
# 4. Gratuitous ARP pour forcer la mise à jour immédiate des tables ARP des switches et téléphones SIP
VIP=$(sqlite3 "$DB_PATH" "SELECT virtual_ip FROM cluster_settings WHERE id=1;" 2>/dev/null || echo "")
IFACE=$(sqlite3 "$DB_PATH" "SELECT interface FROM cluster_settings WHERE id=1;" 2>/dev/null || echo "eth0")
VIP_CLEAN=$(echo "$VIP" | cut -d'/' -f1)
if [ -n "$VIP_CLEAN" ] && command -v arping >/dev/null 2>&1; then
echo "[$TIMESTAMP] [CLUSTER] Émission Gratuitous ARP pour $VIP_CLEAN sur $IFACE..." >> "$LOG_FILE"
arping -c 5 -U -I "$IFACE" "$VIP_CLEAN" >> "$LOG_FILE" 2>&1 || true
fi
# 5. Réveil progressif et réabonnement BLF des postes téléphoniques
# Dès que les postes se réenregistrent sur ce Master (via la VIP), un signal silencieux check-sync
# leur est transmis pour forcer la synchronisation immédiate de l'ensemble des touches BLF.
if pidof asterisk >/dev/null 2>&1; then
echo "[$TIMESTAMP] [CLUSTER] Lancement du réveil BLF progressif en arrière-plan..." >> "$LOG_FILE"
(
for delay in 2 5 12 25; do
sleep "$delay"
if ! pidof asterisk >/dev/null 2>&1; then break; fi
# Cibler uniquement les postes actuellement joignables (Avail)
for ep in $(/usr/sbin/asterisk -rx "pjsip show contacts" 2>/dev/null | grep -E 'Contact:.*Avail' | awk '{print $2}' | cut -d'/' -f1 | sort -u); do
if [ -n "$ep" ]; then
/usr/sbin/asterisk -rx "pjsip send notify check-sync endpoint $ep" >> "$LOG_FILE" 2>&1 || true
fi
done
done
) >/dev/null 2>&1 &
fi
echo "[$TIMESTAMP] [CLUSTER] Prise de relais MASTER terminée avec succès." >> "$LOG_FILE"
exit 0
-193
View File
@@ -1,193 +0,0 @@
#!/usr/bin/env bash
# ==============================================================================
# NexusPBX - Script de Réparation Automatique des Permissions & Services
# ==============================================================================
set -e
if [ "$(id -u)" -ne 0 ]; then
echo "[ERREUR] Ce script doit être exécuté en root."
exit 1
fi
echo "-> Correction des répertoires Git et mise à jour..."
git config --global --add safe.directory /var/www/nexus-pbx 2>/dev/null || true
cd /var/www/nexus-pbx
git fetch origin main
git reset --hard origin/main
echo "-> Installation des dépendances système indispensables (ACL, ffmpeg, rsync, sox, sshpass)..."
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
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
chown www-data:asterisk /var/log/nexus_security.log 2>/dev/null || true
chmod 0664 /var/log/nexus_security.log 2>/dev/null || true
echo "-> Filtres Fail2ban Nexus-Web..."
cat << 'EOF' > /etc/fail2ban/filter.d/nexus-web.conf
[Definition]
failregex = ^.*\[NEXUS-WEB-AUTH-FAIL\]:\s*<HOST>.*$
ignoreregex =
EOF
chmod 0644 /etc/fail2ban/filter.d/nexus-web.conf
echo "-> Génération et application de /etc/fail2ban/jail.local..."
php -r '
require_once "/var/www/nexus-pbx/src/Database.php";
require_once "/var/www/nexus-pbx/src/FirewallManager.php";
$st = App\FirewallManager::getSettings();
App\FirewallManager::generateJailLocal(
$st["fail2ban_whitelist_ips"],
(int)$st["fail2ban_sip_maxretry"],
(int)$st["fail2ban_sip_findtime"],
(int)$st["fail2ban_sip_bantime"],
(int)$st["fail2ban_web_maxretry"],
(int)$st["fail2ban_web_bantime"],
(int)$st["admin_https_port"]
);
App\FirewallManager::applyNginxConfig((int)$st["admin_https_port"], (int)$st["certbot_strict_port80"]);
' 2>/dev/null || true
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
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
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
# 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
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 /var/spool/asterisk/voicemail 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
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
setfacl -m u:www-data:rw /etc/fail2ban/jail.local /etc/nginx/sites-available/nexus-pbx 2>/dev/null || true
cat << 'EOF' > /etc/sudoers.d/nexus-pbx
www-data ALL=(ALL) NOPASSWD: /usr/sbin/asterisk, /usr/sbin/nginx, /usr/bin/systemctl, /usr/bin/fail2ban-client, /bin/fail2ban-client, /var/www/nexus-pbx/scripts/nexus-firewall, /usr/bin/certbot, /usr/sbin/nft, /usr/sbin/iptables, /var/www/nexus-pbx/scripts/update.sh, /usr/local/bin/nexus-update
EOF
chmod 0440 /etc/sudoers.d/nexus-pbx
echo "-> Autorisation d'écriture systemd pour PHP-FPM (Debian 12/13 ProtectSystem)..."
for fpm_svc in /lib/systemd/system/php*-fpm.service /usr/lib/systemd/system/php*-fpm.service; do
if [ -f "$fpm_svc" ]; then
svc_name=$(basename "$fpm_svc")
mkdir -p "/etc/systemd/system/${svc_name}.d"
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
EOF_OVERRIDE
fi
done
systemctl daemon-reload
echo "-> Configuration des limites d'upload PHP (512 Mo)..."
for php_dir in /etc/php/*; do
if [ -d "$php_dir/fpm/conf.d" ]; then
cat << 'EOF_PHP' > "$php_dir/fpm/conf.d/99-nexus.ini"
upload_max_filesize = 512M
post_max_size = 512M
memory_limit = 512M
max_execution_time = 300
max_input_time = 300
EOF_PHP
fi
if [ -d "$php_dir/cli/conf.d" ]; then
cat << 'EOF_PHP' > "$php_dir/cli/conf.d/99-nexus.ini"
upload_max_filesize = 512M
post_max_size = 512M
memory_limit = 512M
max_execution_time = 300
max_input_time = 300
EOF_PHP
fi
done
echo "-> Génération des configurations Asterisk..."
php -r '
require_once "/var/www/nexus-pbx/src/Database.php";
require_once "/var/www/nexus-pbx/src/ConfigGenerator.php";
$db = App\Database::getConnection();
$gen = new App\ConfigGenerator($db);
$gen->writeAllConfigs();
' 2>/dev/null || true
echo "-> Redémarrage des services..."
systemctl daemon-reload
systemctl restart php*-fpm 2>/dev/null || true
PHP_SVC=$(systemctl list-unit-files --type=service 2>/dev/null | grep -E "php.*-fpm" | awk '{print $1}' | head -n 1 || echo "")
if [ -n "$PHP_SVC" ]; then
systemctl restart "$PHP_SVC" 2>/dev/null || true
fi
systemctl restart asterisk 2>/dev/null || true
systemctl restart fail2ban 2>/dev/null || true
systemctl restart nginx 2>/dev/null || true
echo ""
echo "=========================================================="
echo " [OK] Réparation terminée ! Toutes les permissions sont actives."
echo "=========================================================="
/var/www/nexus-pbx/scripts/nexus-banner.sh --force || true
+142
View File
@@ -0,0 +1,142 @@
#!/usr/bin/env bash
# ==============================================================================
# NexusPBX HA - Script Helper Root pour l'orchestration Keepalived
# ==============================================================================
set -euo pipefail
export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:$PATH"
CMD="${1:-status}"
case "$CMD" in
apply)
# 1. Activation kernel sysctl pour liaison IP non-locale (indispensable HA)
sysctl -w net.ipv4.ip_nonlocal_bind=1 >/dev/null 2>&1 || true
if [ ! -f /etc/sysctl.d/99-nexus-ha.conf ]; then
echo "net.ipv4.ip_nonlocal_bind=1" > /etc/sysctl.d/99-nexus-ha.conf 2>/dev/null || true
fi
# 2. Vérification et installation éventuelle de Keepalived et arping
if ! command -v keepalived >/dev/null 2>&1 || ! command -v arping >/dev/null 2>&1; then
apt-get update -qq && apt-get install -y -qq keepalived iputils-arping >/dev/null 2>&1 || true
fi
# 3. Création et sécurisation stricte du dossier des scripts Keepalived (exigences strictes Keepalived 2.x)
mkdir -p /etc/keepalived/scripts
if [ -d "/var/www/nexus-pbx/scripts/cluster" ]; then
cp -f /var/www/nexus-pbx/scripts/cluster/*.sh /etc/keepalived/scripts/ 2>/dev/null || true
fi
chown -R root:root /etc/keepalived
chmod 755 /etc/keepalived
chmod 755 /etc/keepalived/scripts
chmod 755 /etc/keepalived/scripts/*.sh 2>/dev/null || true
# 4. Déploiement de la configuration générée depuis /tmp
if [ -f "/tmp/keepalived.conf.tmp" ]; then
cp -f /tmp/keepalived.conf.tmp /etc/keepalived/keepalived.conf
chown root:root /etc/keepalived/keepalived.conf
chmod 644 /etc/keepalived/keepalived.conf
rm -f /tmp/keepalived.conf.tmp
fi
# Fichier journal du cluster accessible en écriture
touch /var/log/nexus_cluster.log
chmod 666 /var/log/nexus_cluster.log
# 5. Activation et démarrage du service
systemctl daemon-reload
systemctl unmask keepalived >/dev/null 2>&1 || true
systemctl enable keepalived >/dev/null 2>&1 || true
systemctl restart keepalived
echo "KEEPALIVED_APPLIED_OK"
;;
stop)
systemctl stop keepalived >/dev/null 2>&1 || true
systemctl disable keepalived >/dev/null 2>&1 || true
iptables -D INPUT -p udp -m multiport --dports 5060,5061 -j REJECT --reject-with icmp-port-unreachable 2>/dev/null || true
iptables -D INPUT -p tcp -m multiport --dports 5060,5061 -j REJECT --reject-with tcp-reset 2>/dev/null || true
echo "KEEPALIVED_STOPPED_OK"
;;
status)
if systemctl is-active --quiet keepalived 2>/dev/null; then
echo "RUNNING"
else
echo "STOPPED"
fi
;;
restore-astdb)
SRC="${2:-/tmp/astdb_cluster_sync.sqlite3}"
DEST="/var/lib/asterisk/astdb.sqlite3"
if [ ! -f "$SRC" ] || [ ! -s "$SRC" ]; then
echo "ASTDB_RESTORE_NO_FILE"
exit 0
fi
# 1. Vérification intégrité du fichier source temporaire
SRC_INTEGRITY=$(sqlite3 "$SRC" "PRAGMA integrity_check;" 2>/dev/null || echo "corrupt")
if [ "$SRC_INTEGRITY" != "ok" ]; then
echo "ASTDB_SOURCE_CORRUPT: $SRC_INTEGRITY"
rm -f "$SRC"
exit 1
fi
chmod 644 "$SRC" 2>/dev/null || true
# 2. Si Asterisk tourne, injection via son moteur interne database query
# Cela garantit l'absence totale de verrou SQLite (database is locked) et préserve les descripteurs ouverts
# IMPORTANT : Ne jamais répliquer /subscription_persistence/ car les dialogues SIP BLF sont des objets en RAM
# propres à l'instance Asterisk d'origine. Les purger force le nouveau Master à envoyer 481, ce qui incite
# immédiatement les téléphones (Yealink T46S, T54W) à réémettre un dialogue BLF propre en 25ms.
if pidof asterisk >/dev/null 2>&1; then
/usr/sbin/asterisk -rx "database query \"ATTACH '$SRC' AS src;\"" >/dev/null 2>&1 || true
/usr/sbin/asterisk -rx "database query \"DELETE FROM astdb WHERE key LIKE '/registrar/%';\"" >/dev/null 2>&1 || true
/usr/sbin/asterisk -rx "database query \"INSERT INTO astdb SELECT * FROM src.astdb WHERE key LIKE '/registrar/%';\"" >/dev/null 2>&1 || true
/usr/sbin/asterisk -rx "database query \"INSERT OR REPLACE INTO astdb SELECT * FROM src.astdb WHERE key NOT LIKE '/registrar/%' AND key NOT LIKE '/subscription_persistence/%';\"" >/dev/null 2>&1 || true
/usr/sbin/asterisk -rx "database query \"DETACH src;\"" >/dev/null 2>&1 || true
/usr/sbin/asterisk -rx "database deltree subscription_persistence" >/dev/null 2>&1 || true
else
# Asterisk arrêté : copie directe intègre en purgeant les souscriptions zombies
cp -f "$SRC" "$DEST"
sqlite3 "$DEST" "DELETE FROM astdb WHERE key LIKE '/subscription_persistence/%';" 2>/dev/null || true
chown asterisk:asterisk "$DEST" 2>/dev/null || true
chmod 664 "$DEST" 2>/dev/null || true
fi
rm -f "$SRC"
echo "ASTDB_RESTORED_OK"
;;
fix-astdb)
DEST="/var/lib/asterisk/astdb.sqlite3"
if [ -f "$DEST" ]; then
INTEGRITY=$(sqlite3 "$DEST" "PRAGMA integrity_check;" 2>/dev/null || echo "corrupt")
if [ "$INTEGRITY" != "ok" ]; then
mv -f "$DEST" "${DEST}.corrupt.$(date +%s)" 2>/dev/null || true
sqlite3 "$DEST" "CREATE TABLE astdb(key TEXT, value TEXT, PRIMARY KEY(key));" 2>/dev/null || true
chown asterisk:asterisk "$DEST" 2>/dev/null || true
chmod 664 "$DEST" 2>/dev/null || true
if pidof asterisk >/dev/null 2>&1; then
/usr/sbin/asterisk -rx "pjsip reload" 2>/dev/null || true
fi
echo "ASTDB_FIXED"
exit 0
fi
fi
echo "ASTDB_OK"
;;
check)
if pidof asterisk >/dev/null 2>&1 && /usr/sbin/asterisk -rx "core show uptime" 2>/dev/null | grep -qi "uptime"; then
echo "HEALTHY"
else
echo "DEGRADED"
fi
;;
*)
echo "Usage: $0 {apply|stop|status|check}"
exit 1
;;
esac
+59
View File
@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
/**
* NexusPBX HA - Synchronisation périodique du cluster par cron
* Exécuté automatiquement pour s'assurer que le nœud Backup reste 100% synchronisé.
*/
require_once __DIR__ . '/../src/Database.php';
require_once __DIR__ . '/../src/ClusterManager.php';
require_once __DIR__ . '/../src/ConfigGenerator.php';
use App\Database;
use App\ClusterManager;
try {
// Mode réconciliation immédiate au Failback (appelé par notify_master.sh)
if (in_array('--failback-check', $argv ?? [], true)) {
$db = Database::getConnection();
$cm = new ClusterManager($db);
$res = $cm->failbackReconcileWithPeer();
echo date('[Y-m-d H:i:s] ') . "[CLUSTER-FAILBACK] " . ($res['message'] ?? 'OK') . "\n";
exit(0);
}
// 0. Verrou d'exécution exclusif pour éviter les chevauchements
$lockFp = @fopen('/tmp/nexus_cluster_cron.lock', 'c+');
if (!$lockFp || !@flock($lockFp, LOCK_EX | LOCK_NB)) {
exit(0);
}
$db = Database::getConnection();
$cm = new ClusterManager($db);
$st = $cm->getSettings();
// 1. Si le cluster n'est pas activé ou que l'auto-sync est désactivée
if (empty($st['enabled']) || empty($st['auto_sync'])) {
exit(0);
}
// 2. Règle d'or HA : SEUL le nœud qui détient l'IP Virtuelle active (VIP) émet la réplication
// Un nœud passif/standby ne doit JAMAIS écraser le nœud actif ni émettre en boucle croisée
$vip = $st['virtual_ip'] ?? '';
if (empty($vip) || !$cm->isVirtualIpActive($vip)) {
exit(0);
}
if (empty($st['peer_ip'])) {
exit(0);
}
// 3. Déclenchement de la réplication vers le pair
$result = $cm->pushSyncToPeer();
echo date('[Y-m-d H:i:s] ') . "[CLUSTER-CRON] " . ($result['message'] ?? 'Synchronisation OK') . "\n";
} catch (\Throwable $e) {
echo date('[Y-m-d H:i:s] ') . "[CLUSTER-CRON-ERROR] " . $e->getMessage() . "\n";
exit(1);
}
-4
View File
@@ -1,4 +0,0 @@
#!/usr/bin/env bash
# Redirection vers le script d'installation principal
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
exec bash "$DIR/install.sh" "$@"
-1
View File
@@ -1 +0,0 @@
setup-debian.sh
+68 -17
View File
@@ -81,41 +81,86 @@ fi
echo -e "\n${C_YELLOW}-> Mise à jour vers la version v${REMOTE_VER}...${C_RESET}"
# 1. Sauvegarde préventive automatique
echo -e "${C_GRAY}[1/6] Création d'une sauvegarde préventive du système...${C_RESET}"
mkdir -p "$APP_DIR/storage/backups"
BACKUP_DATE=$(date +%Y%m%d_%H%M%S)
BACKUP_FILE="$APP_DIR/storage/backups/pre-update-${BACKUP_DATE}.tar.gz"
tar -czf "$BACKUP_FILE" \
-C "$APP_DIR/storage" database.sqlite \
-C /etc/asterisk pjsip.custom.conf extensions.custom.conf 2>/dev/null || true
echo -e "${C_GREEN} ✓ Sauvegarde de sécurité créée : $(basename "$BACKUP_FILE")${C_RESET}"
# 1. Sauvegarde préventive automatique (Disaster Recovery complète)
echo -e "${C_GRAY}[1/6] Création d'une sauvegarde préventive du système (Disaster Recovery)...${C_RESET}"
php -r '
require_once "/var/www/nexus-pbx/src/Database.php";
require_once "/var/www/nexus-pbx/src/BackupManager.php";
try {
$res = App\BackupManager::createBackup("pre-update");
echo " ✓ Sauvegarde Disaster Recovery créée : " . $res["filename"] . " (" . $res["size_formatted"] . ")\n";
} catch (\Throwable $e) {
echo " ! Avertissement sauvegarde préventive : " . $e->getMessage() . "\n";
}
' || true
# 2. Application des derniers fichiers de code Git
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
chmod +x "$APP_DIR/scripts/update.sh" "$APP_DIR/scripts/nexus-firewall" "$APP_DIR/scripts/nexus-banner.sh" 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" "$APP_DIR/scripts/nexus-cluster" 2>/dev/null || true
chmod +x "$APP_DIR"/scripts/cluster/*.sh 2>/dev/null || true
# Liens symboliques CLI (non-bloquants si exécuté sous sandbox PHP ProtectSystem)
ln -sf "$APP_DIR/scripts/update.sh" /usr/local/bin/nexus-update 2>/dev/null || true
ln -sf "$APP_DIR/scripts/nexus-firewall" /usr/local/bin/nexus-firewall 2>/dev/null || true
ln -sf "$APP_DIR/scripts/nexus-banner.sh" /usr/local/bin/nexus-banner 2>/dev/null || true
ln -sf "$APP_DIR/scripts/nexus-cluster" /usr/local/bin/nexus-cluster 2>/dev/null || true
# Configuration sysctl pour l'IP flottante (VIP HA)
if [ -w /etc/sysctl.d ]; then
sysctl -w net.ipv4.ip_nonlocal_bind=1 >/dev/null 2>&1 || true
echo "net.ipv4.ip_nonlocal_bind=1" > /etc/sysctl.d/99-nexus-ha.conf 2>/dev/null || true
fi
# Pré-déploiement des scripts Keepalived HA
if [ -d "$APP_DIR/scripts/cluster" ] && [ -w /etc ]; then
mkdir -p /etc/keepalived/scripts 2>/dev/null || true
cp -f "$APP_DIR/scripts/cluster/"*.sh /etc/keepalived/scripts/ 2>/dev/null || true
chown -R root:root /etc/keepalived 2>/dev/null || true
chmod 755 /etc/keepalived /etc/keepalived/scripts 2>/dev/null || true
chmod 755 /etc/keepalived/scripts/*.sh 2>/dev/null || true
fi
# Cron synchronisation Cluster HA
if [ -w /etc/cron.d ]; then
cat << 'EOF_CRON' > /etc/cron.d/nexus-cluster-sync 2>/dev/null || true
# Synchronisation périodique du cluster NexusPBX HA vers le nœud Backup
*/2 * * * * www-data /usr/bin/php /var/www/nexus-pbx/scripts/nexus_cluster_sync.php >> /var/log/nexus_cluster.log 2>&1
EOF_CRON
chmod 0644 /etc/cron.d/nexus-cluster-sync 2>/dev/null || true
touch /var/log/nexus_cluster.log 2>/dev/null || true
chown www-data:asterisk /var/log/nexus_cluster.log 2>/dev/null || true
chmod 666 /var/log/nexus_cluster.log 2>/dev/null || true
fi
# Optimisation de la réserve de processus PHP-FPM
for pool in /etc/php/*/fpm/pool.d/www.conf; do
if [ -f "$pool" ] && [ -w "$pool" ]; then
sed -i 's/^pm.max_children = .*/pm.max_children = 25/' "$pool" 2>/dev/null || true
sed -i 's/^pm.start_servers = .*/pm.start_servers = 4/' "$pool" 2>/dev/null || true
sed -i 's/^pm.min_spare_servers = .*/pm.min_spare_servers = 2/' "$pool" 2>/dev/null || true
sed -i 's/^pm.max_spare_servers = .*/pm.max_spare_servers = 6/' "$pool" 2>/dev/null || true
fi
done
# Mise à jour des privilèges sudoers (www-data) si accessible en écriture
if [ -w /etc/sudoers.d ]; then
cat << 'EOF_SUDO' > /etc/sudoers.d/nexus-pbx 2>/dev/null || true
www-data ALL=(ALL) NOPASSWD: /usr/sbin/asterisk, /usr/sbin/nginx, /usr/bin/systemctl, /usr/bin/fail2ban-client, /bin/fail2ban-client, /var/www/nexus-pbx/scripts/nexus-firewall, /usr/bin/certbot, /usr/sbin/nft, /usr/sbin/iptables, /var/www/nexus-pbx/scripts/update.sh, /usr/local/bin/nexus-update
www-data ALL=(ALL) NOPASSWD: /usr/sbin/asterisk, /usr/sbin/nginx, /usr/bin/systemctl, /usr/bin/fail2ban-client, /bin/fail2ban-client, /var/www/nexus-pbx/scripts/nexus-firewall, /usr/bin/certbot, /usr/sbin/nft, /usr/sbin/iptables, /var/www/nexus-pbx/scripts/update.sh, /usr/local/bin/nexus-update, /var/www/nexus-pbx/scripts/nexus-cluster
EOF_SUDO
chmod 0440 /etc/sudoers.d/nexus-pbx 2>/dev/null || true
fi
# 3. Vérification des dépendances Debian
echo -e "${C_GRAY}[3/6] Vérification des composants et outils système...${C_RESET}"
which ffmpeg >/dev/null 2>&1 && which rsync >/dev/null 2>&1 && which sox >/dev/null 2>&1 && which sshpass >/dev/null 2>&1 || {
which ffmpeg >/dev/null 2>&1 && which rsync >/dev/null 2>&1 && which sox >/dev/null 2>&1 && which sshpass >/dev/null 2>&1 && which keepalived >/dev/null 2>&1 && which arping >/dev/null 2>&1 || {
echo -e " -> Installation de paquets complémentaires..."
(apt-get update -y -qq && apt-get install -y -qq --no-install-recommends acl ffmpeg rsync sox sshpass) 2>/dev/null || true
(apt-get update -y -qq && apt-get install -y -qq --no-install-recommends acl ffmpeg rsync sox sshpass keepalived iputils-arping) 2>/dev/null || true
}
# 4. Exécution des migrations SQLite et régénération Dialplan
@@ -140,14 +185,20 @@ 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 2>/dev/null || true
chmod 2775 /var/spool/asterisk /var/spool/asterisk/voicemail /var/lib/asterisk/sounds /var/lib/asterisk/moh 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
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
+36 -5
View File
@@ -11,7 +11,22 @@ use InvalidArgumentException;
class BackupManager
{
public const BACKUP_DIR = '/var/www/nexus-pbx/storage/system_backups';
public const VERSION = '1.2.0';
public const VERSION = '1.6.2';
/**
* Retourne dynamiquement la version courante de NexusPBX lue depuis version.json
*/
public static function getAppVersion(): string
{
$vFile = dirname(__DIR__) . '/version.json';
if (file_exists($vFile)) {
$data = json_decode((string)file_get_contents($vFile), true);
if (!empty($data['version'])) {
return (string)$data['version'];
}
}
return self::VERSION;
}
/**
* Initialise le répertoire des sauvegardes.
@@ -182,7 +197,8 @@ class BackupManager
}
$timestamp = date('Ymd_His');
$filename = "nexus_backup_v" . self::VERSION . "_{$timestamp}.tar.gz";
$currentVer = self::getAppVersion();
$filename = "nexus_backup_v{$currentVer}_{$timestamp}.tar.gz";
$localArchivePath = self::BACKUP_DIR . '/' . $filename;
// 2. Préparation du dossier de staging
@@ -268,7 +284,7 @@ class BackupManager
// G. Création du Manifeste
$manifest = [
'nexus_version' => self::VERSION,
'nexus_version' => $currentVer,
'created_at' => date('c'),
'timestamp' => time(),
'filename' => $filename,
@@ -360,6 +376,14 @@ class BackupManager
// Tenter de lire le manifest depuis l'archive sans l'extraire entièrement
$manifest = self::readManifestFromTar($file);
$archiveVer = $manifest['nexus_version'] ?? null;
if (!$archiveVer && preg_match('/_v([0-9]+\.[0-9]+(?:\.[0-9]+)?)/', $name, $vm)) {
$archiveVer = $vm[1];
}
if (!$archiveVer) {
$archiveVer = self::getAppVersion();
}
$backups[] = [
'filename' => $name,
'storage_type' => 'local',
@@ -367,7 +391,7 @@ class BackupManager
'size_formatted' => self::formatBytes($size),
'timestamp' => $mtime,
'date_formatted' => date('d/m/Y H:i:s', $mtime),
'version' => $manifest['nexus_version'] ?? self::VERSION,
'version' => $archiveVer,
'source' => $manifest['source'] ?? 'local',
'is_local' => true,
];
@@ -807,6 +831,13 @@ class BackupManager
$name = basename($fullPath);
if (str_ends_with($name, '.tar.gz')) {
$remoteVer = null;
if (preg_match('/_v([0-9]+\.[0-9]+(?:\.[0-9]+)?)/', $name, $vm)) {
$remoteVer = $vm[1];
}
if (!$remoteVer) {
$remoteVer = self::getAppVersion();
}
$results[] = [
'filename' => $name,
'storage_type' => 'ssh',
@@ -814,7 +845,7 @@ class BackupManager
'size_formatted' => self::formatBytes($size),
'timestamp' => $timestamp > 0 ? $timestamp : time(),
'date_formatted' => date('d/m/Y H:i:s', $timestamp > 0 ? $timestamp : time()),
'version' => self::VERSION,
'version' => $remoteVer,
'source' => 'distant (SSH)',
'is_local' => false,
];
+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);
File diff suppressed because it is too large. Load diff
+255 -20
View File
@@ -15,8 +15,9 @@ class ConfigGenerator
public const MOH_CONF_PATH = '/etc/asterisk/musiconhold.custom.conf';
public const RTP_CONF_PATH = '/etc/asterisk/rtp.conf';
public const FEATURES_CONF_PATH = '/etc/asterisk/features.conf';
public const VOICEMAIL_CONF_PATH = '/etc/asterisk/voicemail.custom.conf';
public const BACKUP_DIR = __DIR__ . '/../storage/backups';
public const VOICEMAIL_CONF_PATH = '/etc/asterisk/voicemail.custom.conf';
public const PJSIP_NOTIFY_CONF_PATH = '/etc/asterisk/pjsip_notify.conf';
public const BACKUP_DIR = __DIR__ . '/../storage/backups';
public function __construct(private PDO $db) {}
@@ -60,6 +61,25 @@ class ConfigGenerator
}
}
// Détection de l'IP Virtuelle (VIP) du Cluster Haute Disponibilité (HA)
$clusterSt = [];
try {
$clusterSt = $this->db->query('SELECT enabled, virtual_ip, blf_expiry FROM cluster_settings WHERE id = 1')->fetch(\PDO::FETCH_ASSOC) ?: [];
} catch (\Throwable) {}
$isHaActive = !empty($clusterSt['enabled']);
$haVip = '';
if ($isHaActive && !empty($clusterSt['virtual_ip'])) {
$parts = explode('/', trim((string)$clusterSt['virtual_ip']));
$candidateVip = trim($parts[0] ?? '');
if (filter_var($candidateVip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
$haVip = $candidateVip;
}
}
// En mode HA, liaison obligatoire sur la VIP pour que tous les paquets SIP et contacts
// annoncent la VIP et non l'IP physique du serveur (élimine la perte des dialogues BLF au failover)
$sipBindHost = ($haVip !== '') ? $haVip : '0.0.0.0';
$lines = [];
$lines[] = "; ===========================================================================";
$lines[] = "; FICHIER GÉNÉRÉ AUTOMATIQUEMENT PAR NEXUSPBX ($date)";
@@ -67,12 +87,27 @@ class ConfigGenerator
$lines[] = "; ===========================================================================";
$lines[] = "";
// 0. Paramètres globaux PJSIP (Keepalive applicatif TLS/TCP toutes les 30s pour préserver les sessions NAT hors réseau)
$lines[] = "; --- PARAMÈTRES GLOBAUX PJSIP (KEEPALIVE NAT / HORS RÉSEAU) ---";
$lines[] = "[global]";
$lines[] = "type = global";
$lines[] = "keep_alive_interval = 30";
$lines[] = "contact_expiration_check_interval = 15";
$lines[] = "";
$tcpKeepaliveLines = [
"tcp_keepalive_enable = yes",
"tcp_keepalive_idle_time = 20",
"tcp_keepalive_interval_time = 5",
"tcp_keepalive_probe_count = 3",
];
// 1. Transport UDP (Postes internes LAN & Trunks standards)
$lines[] = "; --- TRANSPORT UDP (LAN / INTERNE) ---";
$lines[] = "[transport-udp]";
$lines[] = "type = transport";
$lines[] = "protocol = udp";
$lines[] = "bind = 0.0.0.0:{$udpPort}";
$lines[] = "bind = {$sipBindHost}:{$udpPort}";
$lines[] = "symmetric_transport = yes";
foreach ($localNetworks as $net) {
$net = trim((string)$net);
@@ -86,6 +121,9 @@ class ConfigGenerator
if ($extUdpPort > 0) {
$lines[] = "external_signaling_port = {$extUdpPort}";
}
} elseif ($haVip !== '') {
$lines[] = "external_media_address = {$haVip}";
$lines[] = "external_signaling_address = {$haVip}";
}
$lines[] = "allow_reload = no";
$lines[] = "";
@@ -95,7 +133,7 @@ class ConfigGenerator
$lines[] = "[transport-tcp]";
$lines[] = "type = transport";
$lines[] = "protocol = tcp";
$lines[] = "bind = 0.0.0.0:{$udpPort}";
$lines[] = "bind = {$sipBindHost}:{$udpPort}";
$lines[] = "symmetric_transport = yes";
foreach ($localNetworks as $net) {
$net = trim((string)$net);
@@ -109,7 +147,11 @@ class ConfigGenerator
if ($extUdpPort > 0) {
$lines[] = "external_signaling_port = {$extUdpPort}";
}
} elseif ($haVip !== '') {
$lines[] = "external_media_address = {$haVip}";
$lines[] = "external_signaling_address = {$haVip}";
}
array_push($lines, ...$tcpKeepaliveLines);
$lines[] = "allow_reload = no";
$lines[] = "";
@@ -152,14 +194,19 @@ class ConfigGenerator
$lines[] = "[transport-tls]";
$lines[] = "type = transport";
$lines[] = "protocol = tls";
$lines[] = "bind = 0.0.0.0:{$tlsPort}";
$lines[] = "bind = {$sipBindHost}:{$tlsPort}";
$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) {
@@ -174,7 +221,11 @@ class ConfigGenerator
if ($extTlsPort > 0) {
$lines[] = "external_signaling_port = {$extTlsPort}";
}
} elseif ($haVip !== '') {
$lines[] = "external_media_address = {$haVip}";
$lines[] = "external_signaling_address = {$haVip}";
}
array_push($lines, ...$tcpKeepaliveLines);
$lines[] = "allow_reload = no";
$lines[] = "";
@@ -182,6 +233,18 @@ class ConfigGenerator
$lines[] = "; ===========================================================================";
$lines[] = "; POSTES TÉLÉPHONIQUES (EXTENSIONS)";
$lines[] = "; ===========================================================================";
// Détection de l'état du Cluster Haute Disponibilité (HA)
$clusterSt = [];
try {
$clusterSt = $this->db->query('SELECT enabled, blf_expiry FROM cluster_settings WHERE id = 1')->fetch(PDO::FETCH_ASSOC) ?: [];
} catch (\Throwable $e) {}
$isHaActive = !empty($clusterSt['enabled']);
// En mode HA, on plafonne sur la valeur configurée (par défaut 120s).
// Sans HA, on garde 120s-300s afin de maintenir actives les tables NAT des postes hors réseau.
$regExpiration = $isHaActive ? max(30, min(3600, (int)($clusterSt['blf_expiry'] ?? 120))) : 120;
$regMaxExpiration = $isHaActive ? $regExpiration : 300;
foreach ($extensions as $ext) {
$num = trim((string)$ext['extension']);
$name = str_replace('"', '', trim((string)$ext['name']));
@@ -216,6 +279,19 @@ class ConfigGenerator
$lines[] = "force_rport = yes";
$lines[] = "rewrite_contact = yes";
$lines[] = "direct_media = no";
// Détection automatique et purge des flux RTP fantômes / morts (ex: basculement HA, coupure réseau)
$rtpTimeout = max(5, (int)($settings['rtp_timeout'] ?? 30));
$rtpHoldTimeout = max(0, (int)($settings['rtp_timeout_hold'] ?? 0));
$rtpKeepalive = max(0, (int)($settings['rtp_keepalive'] ?? 15));
$lines[] = "rtp_timeout = {$rtpTimeout}";
$lines[] = "rtp_timeout_hold = {$rtpHoldTimeout}";
if ($rtpKeepalive > 0) {
$lines[] = "rtp_keepalive = {$rtpKeepalive}";
}
$lines[] = "timers = yes";
$lines[] = "timers_min_se = 90";
$lines[] = "timers_sess_expires = 1800";
if ($wantsTls && $hasTlsCerts) {
// Verrouillage strict du transport TLS : refuse le repli sur UDP
$lines[] = "transport = transport-tls";
@@ -256,6 +332,9 @@ class ConfigGenerator
$lines[] = "type = aor";
$lines[] = "max_contacts = {$maxContacts}";
$lines[] = "remove_existing = yes";
$lines[] = "default_expiration = {$regExpiration}";
$lines[] = "maximum_expiration = {$regMaxExpiration}";
$lines[] = "minimum_expiration = " . ($isHaActive ? 30 : 60);
$lines[] = "qualify_frequency = 30";
if ($hasVm) {
$lines[] = "mailboxes = {$num}@default";
@@ -287,13 +366,14 @@ class ConfigGenerator
}
$context = trim((string)($trunk['context'] ?? 'from-external')) ?: 'from-external';
$fromUser = trim((string)($trunk['from_user'] ?? '')) ?: $username;
$fromUser = trim((string)($trunk['from_user'] ?? ''));
$fromDomain = trim((string)($trunk['from_domain'] ?? '')) ?: $host;
$callerId = trim((string)($trunk['outbound_callerid'] ?? ''));
$sendPai = ((int)($trunk['send_pai'] ?? 1) === 1) ? 'yes' : 'no';
$sendRpid = ((int)($trunk['send_rpid'] ?? 0) === 1) ? 'yes' : 'no';
$sendDiversion = ((int)($trunk['send_diversion'] ?? 1) === 1) ? 'yes' : 'no';
$trustInbound = ((int)($trunk['trust_id_inbound'] ?? 1) === 1) ? 'yes' : 'no';
$trustOutbound = (!isset($trunk['trust_id_outbound']) || (int)$trunk['trust_id_outbound'] === 1) ? 'yes' : 'no';
$dtmfMode = trim((string)($trunk['dtmf_mode'] ?? 'rfc4733')) ?: 'rfc4733';
$mediaEnc = trim((string)($trunk['media_encryption'] ?? 'no')) ?: 'no';
$natSym = ((int)($trunk['nat_symmetric'] ?? 1) === 1) ? 'yes' : 'no';
@@ -345,9 +425,10 @@ class ConfigGenerator
if ($outboundProxy !== '') {
$lines[] = "outbound_proxy = {$outboundProxy}";
}
if ($callerId !== '') {
$lines[] = "callerid = {$callerId}";
}
// Note : Ne JAMAIS positionner la directive "callerid" dans la section endpoint d'un Trunk PJSIP.
// Dans Asterisk, "callerid" sur un endpoint sert de valeur par défaut pour les appels ENTRANTS,
// ce qui écraserait le numéro de l'appelant externe (ex: mobile 06...).
// Le CallerID sortant du Trunk (outbound_callerid) est appliqué uniquement dans le Dialplan sortant.
$lines[] = "disallow = all";
foreach ($codecs as $codec) {
$lines[] = "allow = {$codec}";
@@ -362,7 +443,12 @@ class ConfigGenerator
$lines[] = "send_rpid = {$sendRpid}";
$lines[] = "send_diversion = {$sendDiversion}";
$lines[] = "trust_id_inbound = {$trustInbound}";
$lines[] = "trust_id_outbound = {$trustOutbound}";
$lines[] = "timers = {$timers}";
$rtpTimeout = max(5, (int)($settings['rtp_timeout'] ?? 30));
$rtpHoldTimeout = max(0, (int)($settings['rtp_timeout_hold'] ?? 0));
$lines[] = "rtp_timeout = {$rtpTimeout}";
$lines[] = "rtp_timeout_hold = {$rtpHoldTimeout}";
if ($rtpKeepalive > 0) {
$lines[] = "rtp_keepalive = {$rtpKeepalive}";
}
@@ -1150,7 +1236,9 @@ class ConfigGenerator
// 3. Positionnement du CallerID Sortant de la route
if ($rCid !== '') {
$sub[] = " same => n,Set(CALLERID(all)={$rCid})";
foreach ($this->buildCallerIdDialplan($rCid) as $cidStmt) {
$sub[] = $cidStmt;
}
}
// 4. Séquence de Trunks avec bascule automatique (Failover)
@@ -1164,8 +1252,10 @@ class ConfigGenerator
$tSlug = $tObj ? $this->slugify($tObj['name']) : $this->slugify($tKey);
$tFallbackCid = $tObj ? trim((string)($tObj['outbound_callerid'] ?? '')) : '';
if ($rCid === '' && $tFallbackCid !== '' && $overrideCid) {
$sub[] = " same => n,Set(CALLERID(all)={$tFallbackCid})";
if ($rCid === '' && $tFallbackCid !== '') {
foreach ($this->buildCallerIdDialplan($tFallbackCid) as $cidStmt) {
$sub[] = $cidStmt;
}
}
$validTrunkCount++;
@@ -1596,6 +1686,30 @@ class ConfigGenerator
$lines[] = " same => n,Hangup()";
$lines[] = "";
// 5b. Contexte dédié Dépôt Direct sur Boîte Vocale (Redirection sans sonnerie)
$lines[] = "; ===========================================================================";
$lines[] = "; DÉPÔT DIRECT SUR BOÎTE VOCALE (REDIRECTION / DÉBORDEMENT)";
$lines[] = "; ===========================================================================";
$lines[] = "[app-voicemail]";
foreach ($extensions as $ext) {
$num = trim((string)$ext['extension']);
if (empty($ext['voicemail_enabled'])) {
continue;
}
$lines[] = "exten => {$num},1,NoOp(Redirection directe vers messagerie vocale du poste {$num})";
$lines[] = " same => n,Set(CHANNEL(language)={$sysLang})";
$lines[] = " same => n,Answer()";
$lines[] = " same => n,Wait(0.5)";
$lines[] = " same => n,VoiceMail({$num}@default,u)";
$lines[] = " same => n,Hangup()";
}
$lines[] = "exten => _X.,1,NoOp(Boite vocale inexistante ou desactivee pour \${EXTEN})";
$lines[] = " same => n,Set(CHANNEL(language)={$sysLang})";
$lines[] = " same => n,Answer()";
$lines[] = " same => n,Playback(vm-incorrect-mailbox)";
$lines[] = " same => n,Hangup()";
$lines[] = "";
return implode("\n", $lines) . "\n";
}
@@ -1734,9 +1848,10 @@ class ConfigGenerator
$rtpEnd = max($rtpStart + 4, min(65535, (int)($settings['rtp_end'] ?? 20000)));
$checksums = !empty($settings['rtp_checksums']) ? 'yes' : 'no';
$strict = !empty($settings['rtp_strict']) ? 'yes' : 'no';
$timeout = max(5, (int)($settings['rtp_timeout'] ?? 30));
$keepalive = max(0, (int)($settings['rtp_keepalive'] ?? 15));
$date = date('Y-m-d H:i:s');
$timeout = max(5, (int)($settings['rtp_timeout'] ?? 30));
$holdTimeout = max(0, (int)($settings['rtp_timeout_hold'] ?? 0));
$keepalive = max(0, (int)($settings['rtp_keepalive'] ?? 15));
$date = date('Y-m-d H:i:s');
$lines = [];
$lines[] = "; ===========================================================================";
@@ -1749,7 +1864,7 @@ class ConfigGenerator
$lines[] = "rtpchecksums = {$checksums}";
$lines[] = "strictrtp = {$strict}";
$lines[] = "rtptimeout = {$timeout}";
$lines[] = "rtpholdtimeout = 300";
$lines[] = "rtpholdtimeout = {$holdTimeout}";
if ($keepalive > 0) {
$lines[] = "rtpkeepalive = {$keepalive}";
}
@@ -1800,6 +1915,10 @@ class ConfigGenerator
$lines[] = "; MESSAGERIE VOCALE (VOICEMAIL) - CONTEXTE [default]";
$lines[] = "; ===========================================================================";
$lines[] = "";
$lines[] = "[general](+)";
$lines[] = "pollmailboxes = yes";
$lines[] = "pollfreq = 10";
$lines[] = "";
$lines[] = "[default](+)";
foreach ($extensions as $ext) {
@@ -1842,6 +1961,48 @@ class ConfigGenerator
return implode("\n", $lines) . "\n";
}
/**
* Génère la configuration des événements SIP Notify (/etc/asterisk/pjsip_notify.conf)
* Profils check-sync pour Yealink, Fanvil, Snom, Polycom, etc.
*/
public function generatePjsipNotifyConf(): string
{
return <<<EOF
; ===========================================================================
; CONFIGURATION DES NOTIFICATIONS SIP PJSIP - NexusPBX
; Profils de resynchronisation immédiate (check-sync) Yealink, Fanvil, Snom, etc.
; ===========================================================================
[check-sync]
Event=>check-sync\;reboot=false
[reboot]
Event=>check-sync\;reboot=true
[yealink-check-cfg]
Event=>check-sync\;reboot=false
[yealink-reboot]
Event=>check-sync\;reboot=true
[snom-check-cfg]
Event=>check-sync\;reboot=false
[snom-reboot]
Event=>check-sync\;reboot=true
[polycom-check-cfg]
Event=>check-sync
[cisco-check-cfg]
Event=>check-sync
[aastra-check-cfg]
Event=>check-sync
EOF;
}
public function writeAllConfigs(): array
{
if (!is_dir(self::BACKUP_DIR)) {
@@ -1854,6 +2015,7 @@ class ConfigGenerator
$rtpContent = $this->generateRtpConf();
$featuresContent = $this->generateFeaturesConf();
$voicemailContent = $this->generateVoicemailConf();
$notifyContent = $this->generatePjsipNotifyConf();
$this->writeAtomicWithPermissions(self::PJSIP_CONF_PATH, $pjsipContent);
$this->writeAtomicWithPermissions(self::EXTENSIONS_CONF_PATH, $extensionsContent);
@@ -1861,6 +2023,7 @@ class ConfigGenerator
$this->writeAtomicWithPermissions(self::RTP_CONF_PATH, $rtpContent);
$this->writeAtomicWithPermissions(self::FEATURES_CONF_PATH, $featuresContent);
$this->writeAtomicWithPermissions(self::VOICEMAIL_CONF_PATH, $voicemailContent);
$this->writeAtomicWithPermissions(self::PJSIP_NOTIFY_CONF_PATH, $notifyContent);
// S'assurer que pjsip.conf et extensions.conf incluent bien les fichiers .custom.conf
SystemChecker::ensureAsteriskIncludes();
@@ -2189,6 +2352,17 @@ class ConfigGenerator
if (preg_match('/^\d+$/', $dest)) {
return "Goto(from-internal,{$dest},1)";
}
if (str_starts_with($dest, 'vm-')) {
$vmExt = preg_replace('/[^0-9A-Za-z_-]/', '', substr($dest, 3));
if ($vmExt !== '') {
$stmt = $this->db->prepare('SELECT voicemail_enabled FROM extensions WHERE extension = :ext AND enabled = 1');
$stmt->execute([':ext' => $vmExt]);
if ($stmt->fetchColumn()) {
return "Goto(app-voicemail,{$vmExt},1)";
}
}
return 'Goto(app-terminate,hangup,1)';
}
if (str_starts_with($dest, 'page-')) {
$pageExt = substr($dest, 5);
return "Goto(from-internal,{$pageExt},1)";
@@ -2219,18 +2393,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;
@@ -2243,5 +2423,60 @@ class ConfigGenerator
$slug = preg_replace('/[^a-z0-9]+/', '-', $slug);
return trim($slug, '-') ?: 'default';
}
/**
* Formate les instructions Dialplan pour le positionnement du Caller ID (Nom, Numéro, Présentation, CallerID all)
* afin de garantir la transmission du CNAM (Caller ID Name) et du CLI par Asterisk PJSIP.
*
* @param string $cidString Chaîne du type '"Sytelis Consulting" <0422560185>', 'Sytelis <0422560185>', '0422560185' ou 'Sytelis'
* @param string $indent
* @return array<string>
*/
private function buildCallerIdDialplan(string $cidString, string $indent = ' same => n,'): array
{
$cidString = trim($cidString);
if ($cidString === '') {
return [];
}
$name = '';
$num = '';
// Formats reconnus :
// 1. "Nom Complet" <0422560185> ou Nom Complet <0422560185>
if (preg_match('/^(?:\"?([^\"<>]+?)\"?\s*)?<([^>]+)>$/u', $cidString, $m)) {
$name = trim($m[1] ?? '');
$num = trim($m[2] ?? '');
} elseif (preg_match('/^<([^>]+)>$/', $cidString, $m)) {
$num = trim($m[1]);
} elseif (preg_match('/^\+?[0-9*#]+$/', $cidString)) {
$num = $cidString;
} else {
// Uniquement un nom
$name = trim($cidString, " \t\n\r\0\x0B\"");
}
$lines = [];
if ($num !== '') {
$lines[] = "{$indent}Set(CALLERID(num)={$num})";
$lines[] = "{$indent}Set(CALLERID(num-pres)=allowed_passed_screen)";
}
if ($name !== '') {
$cleanName = str_replace('"', '', $name);
$lines[] = "{$indent}Set(CALLERID(name)=\"{$cleanName}\")";
$lines[] = "{$indent}Set(CALLERID(name-pres)=allowed_passed_screen)";
}
if ($name !== '' && $num !== '') {
$cleanName = str_replace('"', '', $name);
$lines[] = "{$indent}Set(CALLERID(all)=\"{$cleanName}\" <{$num}>)";
} elseif ($num !== '') {
$lines[] = "{$indent}Set(CALLERID(all)={$num})";
} elseif ($name !== '') {
$cleanName = str_replace('"', '', $name);
$lines[] = "{$indent}Set(CALLERID(all)=\"{$cleanName}\")";
}
return $lines;
}
}
+90 -4
View File
@@ -29,7 +29,7 @@ class Database
mkdir($dir, 0770, true);
}
$isNew = !file_exists(self::DB_PATH);
$isNew = !file_exists(self::DB_PATH) || filesize(self::DB_PATH) === 0;
self::$instance = new PDO('sqlite:' . self::DB_PATH, null, null, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
@@ -41,10 +41,17 @@ class Database
self::$instance->exec('PRAGMA journal_mode = WAL;');
self::$instance->exec('PRAGMA busy_timeout = 5000;');
self::initializeSchema();
if ($isNew) {
self::initializeSchema();
@chmod(self::DB_PATH, 0660);
} else {
// Si la base existe déjà, éviter d'exécuter l'intégralité du DDL à chaque requête HTTP
// ce qui sature SQLite de verrous exclusifs inutiles. On s'assure des colonnes requises.
try {
self::$instance->query("SELECT rtp_timeout_hold FROM settings LIMIT 1");
} catch (\Throwable $e) {
self::initializeSchema();
}
}
}
@@ -82,6 +89,7 @@ class Database
rtp_checksums INTEGER NOT NULL DEFAULT 1,
rtp_strict INTEGER NOT NULL DEFAULT 1,
rtp_timeout INTEGER NOT NULL DEFAULT 30,
rtp_timeout_hold INTEGER NOT NULL DEFAULT 0,
rtp_keepalive INTEGER NOT NULL DEFAULT 15,
allow_anonymous_sip INTEGER NOT NULL DEFAULT 0,
last_applied_at DATETIME DEFAULT NULL,
@@ -150,6 +158,7 @@ class Database
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)),
trust_id_outbound INTEGER NOT NULL DEFAULT 1 CHECK (trust_id_outbound 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',
@@ -306,6 +315,29 @@ class Database
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- 15. Table cluster_settings : Haute Disponibilité (Cluster HA Actif/Passif Keepalived/VRRP)
CREATE TABLE IF NOT EXISTS cluster_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
enabled INTEGER NOT NULL DEFAULT 0 CHECK (enabled IN (0, 1)),
role VARCHAR(16) NOT NULL DEFAULT 'master',
virtual_ip VARCHAR(45) NOT NULL DEFAULT '',
virtual_netmask VARCHAR(16) NOT NULL DEFAULT '24',
interface VARCHAR(32) NOT NULL DEFAULT 'eth0',
peer_ip VARCHAR(45) NOT NULL DEFAULT '',
peer_port INTEGER NOT NULL DEFAULT 8443,
cluster_secret VARCHAR(64) NOT NULL DEFAULT '',
vrid INTEGER NOT NULL DEFAULT 51,
priority INTEGER NOT NULL DEFAULT 100,
blf_expiry INTEGER NOT NULL DEFAULT 120,
sync_interval INTEGER NOT NULL DEFAULT 15,
auto_sync INTEGER NOT NULL DEFAULT 1,
last_sync_at DATETIME DEFAULT NULL,
last_sync_status VARCHAR(32) NOT NULL DEFAULT 'none',
last_sync_message TEXT NOT NULL DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
SQL;
$db->exec($schema);
@@ -321,6 +353,7 @@ class Database
self::migrateFeatureCodesTable($db);
self::migratePhonebookContactsTable($db);
self::migratePagingGroupsTable($db);
self::migrateClusterSettingsTable($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();
@@ -360,6 +393,7 @@ class Database
'rtp_checksums' => "INTEGER NOT NULL DEFAULT 1",
'rtp_strict' => "INTEGER NOT NULL DEFAULT 1",
'rtp_timeout' => "INTEGER NOT NULL DEFAULT 30",
'rtp_timeout_hold' => "INTEGER NOT NULL DEFAULT 0",
'rtp_keepalive' => "INTEGER NOT NULL DEFAULT 15",
'allow_anonymous_sip' => "INTEGER NOT NULL DEFAULT 0",
'system_language' => "VARCHAR(16) NOT NULL DEFAULT 'fr'",
@@ -525,6 +559,7 @@ class Database
'moh_sound_id' => "INTEGER NOT NULL DEFAULT 0",
'cid_name_prefix' => "VARCHAR(64) NOT NULL DEFAULT ''",
'dial_options' => "VARCHAR(32) NOT NULL DEFAULT 'tTR'",
'trust_id_outbound' => "INTEGER NOT NULL DEFAULT 1 CHECK (trust_id_outbound IN (0, 1))",
];
foreach ($extraColumns as $colName => $colDef) {
@@ -747,8 +782,59 @@ class Database
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
");
}
// Aucun groupe de paging par défaut pour conserver une base propre
private static function migrateClusterSettingsTable(PDO $db): void
{
$db->exec("
CREATE TABLE IF NOT EXISTS cluster_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
enabled INTEGER NOT NULL DEFAULT 0 CHECK (enabled IN (0, 1)),
role VARCHAR(16) NOT NULL DEFAULT 'master',
virtual_ip VARCHAR(45) NOT NULL DEFAULT '',
virtual_netmask VARCHAR(16) NOT NULL DEFAULT '24',
interface VARCHAR(32) NOT NULL DEFAULT 'eth0',
peer_ip VARCHAR(45) NOT NULL DEFAULT '',
peer_port INTEGER NOT NULL DEFAULT 8443,
cluster_secret VARCHAR(64) NOT NULL DEFAULT '',
vrid INTEGER NOT NULL DEFAULT 51,
priority INTEGER NOT NULL DEFAULT 100,
sync_interval INTEGER NOT NULL DEFAULT 15,
auto_sync INTEGER NOT NULL DEFAULT 1,
last_sync_at DATETIME DEFAULT NULL,
last_sync_status VARCHAR(32) NOT NULL DEFAULT 'none',
last_sync_message TEXT NOT NULL DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
");
// Migration de la colonne peer_port si elle n'existe pas encore
try {
$db->exec("ALTER TABLE cluster_settings ADD COLUMN peer_port INTEGER NOT NULL DEFAULT 8443");
} catch (\Throwable $e) {}
// Migration de la colonne blf_expiry si elle n'existe pas encore
try {
$db->exec("ALTER TABLE cluster_settings ADD COLUMN blf_expiry INTEGER NOT NULL DEFAULT 120");
} catch (\Throwable $e) {}
$count = (int)$db->query('SELECT COUNT(*) FROM cluster_settings')->fetchColumn();
if ($count === 0) {
$defaultSecret = bin2hex(random_bytes(16));
$stmt = $db->prepare('
INSERT INTO cluster_settings (id, enabled, role, virtual_ip, virtual_netmask, interface, peer_ip, cluster_secret, vrid, priority, sync_interval, auto_sync)
VALUES (1, 0, :role, :vip, :vmask, :iface, :peer, :secret, 51, 100, 15, 1)
');
$stmt->execute([
':role' => 'master',
':vip' => '',
':vmask' => '24',
':iface' => 'eth0',
':peer' => '',
':secret' => $defaultSecret,
]);
}
}
}
+3
View File
@@ -235,6 +235,9 @@ class SystemChecker
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');
+13 -7
View File
@@ -1,12 +1,18 @@
{
"version": "1.4.8",
"codename": "Helios",
"release_date": "2026-11-04",
"version": "2.0.0",
"codename": "Pulsar",
"release_date": "2026-11-11",
"min_php_version": "8.2",
"changelog": [
"Correction du routage des codes de test direct SVI/IVR (*801, *802...) et suppression des conflits de préfixe avec l'interphonie (*80) et l'interception de groupe (*8)",
"Restauration automatique des liens de médiathèque audio Debian (/usr/share/asterisk/sounds/fr -> /var/lib/asterisk/sounds) sur toute installation fraîche",
"Définition systématique de la langue française dans les contextes SVI (CHANNEL(language)=fr) pour une lecture parfaite des annonces système",
"Amélioration de la robustesse de l'interphonie 1-à-1 avec ciblage strict des postes"
"NexusPBX 2.0 « Pulsar » : Version Majeure Haute Disponibilité Télécom & Résilience",
"Haute Disponibilité (HA) : cluster actif/passif avec IP Virtuelle flottante (VIP) et consensus Keepalived VRRP",
"HA Télécom : liaison stricte des transports PJSIP sur la VIP et annonce exclusive de la VIP en signalisation/média",
"HA Télécom : isolation SIP stricte (iptables REJECT) sur nœud Standby et purge de subscription_persistence AstDB éliminant tout dialogue BLF zombie au failover",
"HA Télécom : réveil BLF progressif automatique (check-sync) dès réenregistrement des postes sur le nouveau Master",
"HA Données & Sécurité : réplication atomique SQLite (VACUUM INTO), synchro miroir voicemails & sons personnalisés (rsync --delete), propagation des IP bannies Fail2ban et réconciliation automatique au failback (--failback-check)",
"Postes Hors Réseau & NAT/TLS : keepalive applicatif 30s (keep_alive_interval), TCP keepalives noyau (20s/5s/3), rtp_timeout, rtp_keepalive et SIP Session Timers (RFC 4028) sur tous les endpoints et trunks",
"Routage & Groupes d'Appel : nouvelle destination directe Répondeur (vm-<poste>) en débordement, SVI et routes entrantes",
"Sécurité & Téléphonie : détection temps réel des conversations actives avant redémarrage de poste avec confirmation sécurisée",
"Paramètres SIP : fiabilisation du rtp_timeout_hold et expiration AOR plafonnée (120s-300s) pour maintenir les tunnels NAT actifs"
]
}