Compare commits

...
66 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
sorcier 8f4ea50452 fix(ivr): resolve dialplan conflict on *801-*899 and link debian sound prompts (v1.4.8) 2026-11-04 11:51:21 +01:00
26 changed files with 4084 additions and 310 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
```
+78 -17
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
@@ -88,8 +93,8 @@ if ! apt-get install -y asterisk; then
apt-get install -y -t sid asterisk
fi
# Sons Asterisk en français (exacts de la machine de dev)
apt-get install -y asterisk-core-sounds-fr asterisk-core-sounds-fr-wav asterisk-core-sounds-fr-gsm 2>/dev/null || true
# Sons Asterisk en français et anglais
apt-get install -y asterisk-core-sounds-fr asterisk-core-sounds-fr-wav asterisk-core-sounds-fr-gsm asterisk-core-sounds-en asterisk-core-sounds-en-gsm 2>/dev/null || true
# Nginx, PHP-FPM et extensions requises
apt-get install -y --no-install-recommends \
@@ -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
@@ -325,6 +348,17 @@ if ! grep -q 'voicemail.custom.conf' /etc/asterisk/voicemail.conf 2>/dev/null; t
fi
mkdir -p /var/spool/asterisk/voicemail/default /var/lib/asterisk/sounds/custom /var/lib/asterisk/moh
for lang in fr en es; do
if [ -d "/usr/share/asterisk/sounds/$lang" ] && [ ! -e "/var/lib/asterisk/sounds/$lang" ]; then
ln -sf "/usr/share/asterisk/sounds/$lang" "/var/lib/asterisk/sounds/$lang"
fi
if [ -d "/var/lib/asterisk/sounds/$lang" ] && [ ! -e "/var/lib/asterisk/sounds/$lang/custom" ]; then
ln -sf /var/lib/asterisk/sounds/custom "/var/lib/asterisk/sounds/$lang/custom" 2>/dev/null || true
fi
done
if [ ! -e "/usr/share/asterisk/sounds/custom" ]; then
ln -sf /var/lib/asterisk/sounds/custom /usr/share/asterisk/sounds/custom 2>/dev/null || true
fi
chown -R asterisk:asterisk /etc/asterisk /var/spool/asterisk /var/lib/asterisk
chmod 0664 /etc/asterisk/*.conf /etc/asterisk/*.custom.conf 2>/dev/null || true
chmod 2750 /etc/asterisk/keys
@@ -454,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
@@ -473,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")
+251 -7
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.7';
$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,
'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
@@ -2021,6 +2190,7 @@ try {
':rtp_checksums' => $rtpChecksums,
':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([
+733 -7
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);
}
@@ -3783,6 +3901,7 @@ document.addEventListener('DOMContentLoaded', () => {
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_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.7';
$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.7';
<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.7';
<!-- 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.7';
<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.7';
<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.7';
<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.7';
<!-- 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.7';
</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.7';
<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.7';
</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.7';
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.7';
</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.7';
<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.7';
</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
-185
View File
@@ -1,185 +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
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
+76 -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
@@ -136,10 +181,24 @@ require_once "/var/www/nexus-pbx/src/SystemChecker.php";
App\SystemChecker::ensureAsteriskIncludes();
' 2>/dev/null || true
mkdir -p /var/spool/asterisk/voicemail/default /var/lib/asterisk/sounds/custom /var/lib/asterisk/moh 2>/dev/null || true
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
for lang in fr en es; do
if [ -d "/usr/share/asterisk/sounds/$lang" ] && [ ! -e "/var/lib/asterisk/sounds/$lang" ]; then
ln -sf "/usr/share/asterisk/sounds/$lang" "/var/lib/asterisk/sounds/$lang" 2>/dev/null || true
fi
if [ -d "/var/lib/asterisk/sounds/$lang" ] && [ ! -e "/var/lib/asterisk/sounds/$lang/custom" ]; then
ln -sf /var/lib/asterisk/sounds/custom "/var/lib/asterisk/sounds/$lang/custom" 2>/dev/null || true
fi
done
if [ ! -e "/usr/share/asterisk/sounds/custom" ]; then
ln -sf /var/lib/asterisk/sounds/custom /usr/share/asterisk/sounds/custom 2>/dev/null || true
fi
chown -R asterisk:asterisk /var/spool/asterisk /var/lib/asterisk/sounds /var/lib/asterisk/moh /var/log/asterisk 2>/dev/null || true
chmod 2775 /var/spool/asterisk /var/spool/asterisk/voicemail /var/lib/asterisk/sounds /var/lib/asterisk/moh /var/log/asterisk /var/log/asterisk/cdr-csv 2>/dev/null || true
mkdir -p /var/log/asterisk/cdr-csv 2>/dev/null || true
touch /var/log/asterisk/cdr-csv/Master.csv 2>/dev/null || true
chmod 0664 /var/log/asterisk/cdr-csv/Master.csv 2>/dev/null || true
setfacl -R -m u:www-data:rwx,g:asterisk:rwx /etc/asterisk /var/lib/asterisk/sounds /var/lib/asterisk/moh /var/spool/asterisk /var/log/asterisk 2>/dev/null || true
setfacl -R -d -m u:www-data:rwx,g:asterisk:rwx /etc/asterisk /var/lib/asterisk/sounds /var/lib/asterisk/moh /var/spool/asterisk /var/log/asterisk 2>/dev/null || true
chmod 0664 /etc/asterisk/*.conf /etc/asterisk/*.custom.conf 2>/dev/null || true
# 6. Rechargement des services
+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
+257 -26
View File
@@ -16,6 +16,7 @@ class ConfigGenerator
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 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}";
}
@@ -571,10 +657,6 @@ class ConfigGenerator
$lines[] = " same => n,Hangup()";
}
if ($groupCode !== '') {
$lines[] = "exten => _{$groupCode}.,1,NoOp(Interception d'appel dirigee vers \${EXTEN:" . strlen($groupCode) . "})";
$lines[] = " same => n,Pickup(\${EXTEN:" . strlen($groupCode) . "}@from-internal)";
$lines[] = " same => n,Hangup()";
$lines[] = "exten => {$groupCode},1,NoOp(Interception d'appel de groupe)";
$lines[] = " same => n,Pickup()";
$lines[] = " same => n,Hangup()";
@@ -986,9 +1068,10 @@ class ConfigGenerator
$lines[] = "";
}
// Interphonie directe 1-à-1 (*80 + numéro de poste)
// Interphonie directe 1-à-1 (*80 + numéro de poste, ex: *80101 pour le poste 101)
// Pattern _*80XX. pour ne cibler que les numéros de postes (au moins 2 chiffres) et ne pas chevaucher *801
$lines[] = "; --- Interphonie directe 1-à-1 (*80 + numéro de poste) ---";
$lines[] = "exten => _*80.,1,NoOp(Interphonie directe vers poste \${EXTEN:3})";
$lines[] = "exten => _*80XX.,1,NoOp(Interphonie directe vers poste \${EXTEN:3})";
$lines[] = " same => n,Set(CHANNEL(language)={$sysLang})";
$lines[] = " same => n,Answer()";
$lines[] = " same => n,Playback(beep)";
@@ -997,13 +1080,14 @@ class ConfigGenerator
$lines[] = "";
}
// Accès direct aux IVR depuis l'interne via *8 + id
// Accès direct aux IVR depuis l'interne via *8 + id (*801, *802...)
if (!empty($ivrs)) {
$lines[] = "; --- Test direct des SVI/IVR depuis l'interne ---";
$lines[] = "; --- Test direct des SVI/IVR depuis l'interne (*801, *802...) ---";
foreach ($ivrs as $ivr) {
$ivrSlug = $this->slugify($ivr['name']);
$testCode = '*8' . str_pad((string)$ivr['id'], 2, '0', STR_PAD_LEFT);
$lines[] = "exten => {$testCode},1,NoOp(Test SVI {$ivr['name']})";
$lines[] = " same => n,Set(CHANNEL(language)={$sysLang})";
$lines[] = " same => n,Goto(ivr-{$ivrSlug},s,1)";
}
$lines[] = "";
@@ -1152,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)
@@ -1166,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++;
@@ -1500,6 +1588,7 @@ class ConfigGenerator
$lines[] = "; --- Serveur Vocal Interactif : {$ivr['name']} ---";
$lines[] = "[ivr-{$ivrSlug}]";
$lines[] = "exten => s,1,NoOp(SVI {$ivr['name']})";
$lines[] = " same => n,Set(CHANNEL(language)={$sysLang})";
$lines[] = " same => n,Answer()";
$lines[] = " same => n,Wait(1)";
$lines[] = " same => n,Set(IVR_INVALID_COUNT=0)";
@@ -1597,12 +1686,33 @@ 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";
}
/**
* Sauvegarde les anciens fichiers et écrit atomiquement pjsip.custom.conf et extensions.custom.conf
* avec les droits Debian asterisk:asterisk (0660).
/**
* Génère les classes Musique d'attente (MOH) personnalisées par site/catégorie
*/
@@ -1739,6 +1849,7 @@ class ConfigGenerator
$checksums = !empty($settings['rtp_checksums']) ? 'yes' : 'no';
$strict = !empty($settings['rtp_strict']) ? 'yes' : 'no';
$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');
@@ -1753,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}";
}
@@ -1804,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) {
@@ -1846,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)) {
@@ -1858,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);
@@ -1865,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();
@@ -2193,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)";
@@ -2223,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;
@@ -2247,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,
]);
}
}
}
+18
View File
@@ -225,6 +225,24 @@ class SystemChecker
@mkdir('/var/spool/asterisk/voicemail/default', 0775, true);
// Liens vers les répertoires de sons Debian (/usr/share/asterisk/sounds -> /var/lib/asterisk/sounds)
@mkdir('/var/lib/asterisk/sounds', 0775, true);
@mkdir('/var/lib/asterisk/sounds/custom', 0775, true);
@mkdir('/var/lib/asterisk/moh', 0775, true);
foreach (['fr', 'en', 'es'] as $lang) {
$src = "/usr/share/asterisk/sounds/{$lang}";
$dst = "/var/lib/asterisk/sounds/{$lang}";
if (is_dir($src) && !file_exists($dst)) {
@symlink($src, $dst);
}
if (is_dir($dst) && !file_exists("{$dst}/custom")) {
@symlink('/var/lib/asterisk/sounds/custom', "{$dst}/custom");
}
}
if (!file_exists('/usr/share/asterisk/sounds/custom')) {
@symlink('/var/lib/asterisk/sounds/custom', '/usr/share/asterisk/sounds/custom');
}
// Synchroniser manager.conf avec le mot de passe AMI de la base SQLite si nécessaire (ex: après restauration de backup)
$mgrConf = '/etc/asterisk/manager.conf';
if (is_file($mgrConf) && is_writable($mgrConf)) {
+13 -7
View File
@@ -1,12 +1,18 @@
{
"version": "1.4.7",
"codename": "Helios",
"release_date": "2026-11-04",
"version": "2.0.0",
"codename": "Pulsar",
"release_date": "2026-11-11",
"min_php_version": "8.2",
"changelog": [
"Détection ultra-robuste de l'état des téléphones IP (support SIPS, statuts sans qualify et fallback infaillible AstDB)",
"Réinitialisation immédiate du compteur d'attaques contrées Fail2ban lors du vidage du journal de sécurité",
"Synchronisation automatique des identifiants AMI (/etc/asterisk/manager.conf) après restauration d'une sauvegarde",
"Correction et fiabilisation de la messagerie vocale (*97 / *98) sur toute installation fraîche Debian"
"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"
]
}