Compare commits

..
105 Commits
Author SHA1 Message Date
sorcier 08d3996648 Merge branch 'dev' 2026-11-12 11:54:26 +01:00
sorcier 5cb3900c67 fix(update): v2.7.1 avoid transient Nginx 502 error during 1-click web update 2026-11-12 11:44:10 +01:00
sorcier 56be0ab37a Merge branch 'dev' 2026-11-12 11:36:56 +01:00
sorcier 64b8339d14 fix(db): auto-migrate ucp_logo column on existing SQLite databases 2026-11-12 11:36:21 +01:00
sorcier ba04af32bb feat(branding): v2.7.0 logo specifique optionnel pour UCP avec repli sur logo principal ou NexusPBX 2026-11-12 11:35:09 +01:00
sorcier d49520aecb Merge branch 'dev' 2026-11-12 10:51:48 +01:00
sorcier ddbaabb657 v2.6.7: Replace Admin & UCP emojis with uniform Heroicons SVG icons 2026-11-12 10:25:40 +01:00
sorcier 70a021ef63 ui(admin): v2.6.6 selecteur de destinations personnalise avec icones SVG style menu 2026-11-12 10:15:24 +01:00
sorcier b8cf932651 ui(admin): suppression des emojis dans les listes de destinations audio 2026-11-12 10:12:34 +01:00
sorcier 871a90c102 ui(admin): v2.6.5 intitules professionnels pour musiques d'attente et annonces en boucle 2026-11-12 10:10:29 +01:00
sorcier f94299def1 ui(ucp): placeholder generique en francais sur la connexion UCP 2026-11-12 10:00:03 +01:00
sorcier d9f1470c64 ui(ucp): nouveau wallpaper clair dedie a la page de connexion UCP 2026-11-12 09:55:32 +01:00
sorcier 5fddf3f275 ui(ucp): eclaircissement lumineux du wallpaper de connexion UCP 2026-11-12 09:49:07 +01:00
sorcier b702e369a8 ui(ucp): fond d'écran wallpaper sur la page de connexion UCP 2026-11-12 09:47:25 +01:00
sorcier c980aa2904 chore(update): verification et installation auto des extensions PHP requises lors des mises a jour 2026-11-12 09:42:57 +01:00
sorcier f918c929f5 fix(phonebook,ucp): v2.6.4 affichage des postes multi-groupes dans chaque onglet de groupe 2026-11-12 02:45:13 +01:00
sorcier 8cb8f1cdf7 ui(ucp): v2.6.3 onglets dynamiques par groupe dans l'Annuaire de l'Entreprise 2026-11-12 02:42:13 +01:00
sorcier 745e9307d7 fix(ucp): v2.6.2 regle stricte host/port GroundWire (NAT > VIP HA > IP locale + port local) 2026-11-12 02:35:46 +01:00
sorcier 789ea79cf2 style(extensions,ucp): v2.6.1 retrait icone smartphone GroundWire et boutons d'action uniformes 2026-11-12 02:09:37 +01:00
sorcier 28efb2d6bc feat(ucp): v2.6.0 provisionnement XML Acrobits GroundWire par poste 2026-11-12 02:04:47 +01:00
sorcier 135a6267db ui(ucp): cartes journal appels mobile style iPhone (v2.5.7) 2026-11-11 23:21:23 +01:00
sorcier 26cac163d6 ui(ucp): en-tête compact mobile et dégradé latéral onglets (v2.5.6) 2026-11-11 23:13:37 +01:00
sorcier d8ec7e4508 fix(ucp): adaptation iPhone/iOS Safari sans auto-zoom ni débordement (v2.5.5) 2026-11-11 23:04:04 +01:00
sorcier c10c980024 fix(ucp): correction journal personnel Mes Appels CDR (v2.5.4) 2026-11-11 22:44:17 +01:00
sorcier d618d426e7 feat(ucp): onglet Notice & Codes de fonction avec fiche PDF imprimable (v2.5.3) 2026-11-11 19:57:49 +01:00
sorcier 2a7bb2a122 chore(update): garantir ACLs nginx/fail2ban lors des mises à jour 2026-11-11 19:54:17 +01:00
sorcier 8df103d7a0 fix(ha): try/finally & purge temp /tmp cluster (v2.5.2) 2026-11-11 19:48:26 +01:00
sorcier c96af67ac9 fix(ucp,ha): auto-déploiement UCP, check SQLite PDO/SQLite3::backup HA, refresh comptes UCP (v2.5.1) 2026-11-11 19:31:32 +01:00
sorcier 72e43f1a9a feat(ucp): NexusPBX v2.5.0 Portail Utilisateur isolé (Refs #4) 2026-11-11 18:47:25 +01:00
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
sorcier 1e288af617 fix(metrics): robust contact detection with AstDB fallback and reset f2b counters on clear log (v1.4.7) 2026-11-04 11:33:08 +01:00
sorcier bda4cb18a6 fix(voicemail): automate modules.conf and voicemail includes in install.sh and fix.sh (v1.4.6) 2026-11-04 11:06:04 +01:00
sorcier 9b2533695e feat(extensions): auto-regenerate and reload Asterisk configs instantly on save/delete (v1.4.5) 2026-11-04 10:39:28 +01:00
sorcier 07b53b8002 fix(ota): asynchronous web reload & fail-safe probe for Safari (v1.4.4) 2026-11-04 10:13:25 +01:00
sorcier bd8b519753 feat(ui): custom modal dialog for update confirmation (v1.4.3) 2026-11-04 10:08:15 +01:00
sorcier 7deee2be61 feat(login): native server-side operator branding & lighter glassmorphism modal (v1.4.2) 2026-11-04 10:03:15 +01:00
sorcier ea73d15eaf feat(ui): add immersive plexus background image and glassmorphism login modal (v1.4.1) 2026-11-04 09:56:53 +01:00
sorcier 0948a8da24 feat(charts): smooth CPU/RAM and Network curves using cubic Bézier splines (v1.4.0) 2026-11-04 09:40:33 +01:00
sorcier c5eb849095 fix(ui): bust browser localStorage and HTTP cache on update, render version server-side (v1.3.9) 2026-11-04 04:03:29 +01:00
sorcier 1d09f06dc2 chore(auth): remove default credentials hint from login screen (v1.3.8) 2026-11-04 03:51:40 +01:00
40 changed files with 9717 additions and 527 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
```
+130 -30
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,16 +171,22 @@ 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
chmod 0664 /var/log/nexus_security.log 2>/dev/null || true
touch /var/log/nexus_security.log /var/log/nexus_ucp_security.log
chown www-data:asterisk /var/log/nexus_security.log /var/log/nexus_ucp_security.log 2>/dev/null || true
chmod 0664 /var/log/nexus_security.log /var/log/nexus_ucp_security.log 2>/dev/null || true
# Droits applicatifs stricts
chown -R www-data:asterisk "$TARGET_DIR"
@@ -177,19 +195,20 @@ chmod 2775 "$TARGET_DIR/storage" \
"$TARGET_DIR/storage/system_backups" \
"$TARGET_DIR/storage/sounds" \
"$TARGET_DIR/public/avatars"
[ -d "$TARGET_DIR/ucp" ] && chmod 0755 "$TARGET_DIR/ucp" "$TARGET_DIR/ucp/assets" 2>/dev/null || true
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, /usr/sbin/ufw, /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 +220,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
@@ -287,23 +311,61 @@ touch /etc/asterisk/pjsip.conf \
/etc/asterisk/pjsip.custom.conf \
/etc/asterisk/extensions.conf \
/etc/asterisk/extensions.custom.conf \
/etc/asterisk/musiconhold.conf \
/etc/asterisk/musiconhold.custom.conf \
/etc/asterisk/voicemail.conf \
/etc/asterisk/voicemail.custom.conf \
/etc/asterisk/modules.conf \
/etc/asterisk/rtp.conf \
/etc/asterisk/features.conf
# 1. Modules Asterisk : Désactivation des modules ODBC/IMAP incompatibles sur Debian (évite conflit bloquant app_voicemail)
if ! grep -q 'noload => app_voicemail_odbc.so' /etc/asterisk/modules.conf 2>/dev/null; then
if grep -q '\[modules\]' /etc/asterisk/modules.conf 2>/dev/null; then
sed -i '/\[modules\]/a noload => app_voicemail_odbc.so\nnoload => app_voicemail_imap.so' /etc/asterisk/modules.conf 2>/dev/null || true
else
printf '[modules]\nautoload=yes\nnoload => app_voicemail_odbc.so\nnoload => app_voicemail_imap.so\n' >> /etc/asterisk/modules.conf
fi
fi
# 2. Inclusions des fichiers custom dans les configurations principales
if ! grep -q 'pjsip.custom.conf' /etc/asterisk/pjsip.conf 2>/dev/null; then
printf '\n#tryinclude "pjsip.custom.conf"\n' >> /etc/asterisk/pjsip.conf
fi
if ! grep -q 'extensions.custom.conf' /etc/asterisk/extensions.conf 2>/dev/null; then
printf '\n#tryinclude "extensions.custom.conf"\n' >> /etc/asterisk/extensions.conf
fi
chown -R asterisk:asterisk /etc/asterisk
if ! grep -q 'musiconhold.custom.conf' /etc/asterisk/musiconhold.conf 2>/dev/null; then
sed -i 's/^\[default\]/;[default]/' /etc/asterisk/musiconhold.conf 2>/dev/null || true
sed -i 's/^mode=files/;mode=files/' /etc/asterisk/musiconhold.conf 2>/dev/null || true
sed -i 's/^directory=moh/;directory=moh/' /etc/asterisk/musiconhold.conf 2>/dev/null || true
printf '\n#tryinclude "/etc/asterisk/musiconhold.custom.conf"\n' >> /etc/asterisk/musiconhold.conf
fi
if ! grep -q 'voicemail.custom.conf' /etc/asterisk/voicemail.conf 2>/dev/null; then
sed -i 's/^1234 => 4242/;1234 => 4242/' /etc/asterisk/voicemail.conf 2>/dev/null || true
sed -i 's/^;\s*passwordlocation\s*=\s*spooldir/passwordlocation=spooldir/' /etc/asterisk/voicemail.conf 2>/dev/null || true
sed -i 's|^;\s*externpassnotify\s*=.*|externpassnotify=/usr/bin/php /var/www/nexus-pbx/scripts/voicemail_pass_sync.php|' /etc/asterisk/voicemail.conf 2>/dev/null || true
printf '\n#tryinclude "voicemail.custom.conf"\n' >> /etc/asterisk/voicemail.conf
fi
mkdir -p /var/spool/asterisk/voicemail/default /var/lib/asterisk/sounds/custom /var/lib/asterisk/moh
for lang in fr en es; do
if [ -d "/usr/share/asterisk/sounds/$lang" ] && [ ! -e "/var/lib/asterisk/sounds/$lang" ]; then
ln -sf "/usr/share/asterisk/sounds/$lang" "/var/lib/asterisk/sounds/$lang"
fi
if [ -d "/var/lib/asterisk/sounds/$lang" ] && [ ! -e "/var/lib/asterisk/sounds/$lang/custom" ]; then
ln -sf /var/lib/asterisk/sounds/custom "/var/lib/asterisk/sounds/$lang/custom" 2>/dev/null || true
fi
done
if [ ! -e "/usr/share/asterisk/sounds/custom" ]; then
ln -sf /var/lib/asterisk/sounds/custom /usr/share/asterisk/sounds/custom 2>/dev/null || true
fi
chown -R asterisk:asterisk /etc/asterisk /var/spool/asterisk /var/lib/asterisk
chmod 0664 /etc/asterisk/*.conf /etc/asterisk/*.custom.conf 2>/dev/null || true
chmod 2750 /etc/asterisk/keys
chmod 2775 /etc/asterisk
setfacl -R -m u:www-data:rwx,g:asterisk:rwx /etc/asterisk 2>/dev/null || true
setfacl -R -d -m u:www-data:rwx,g:asterisk:rwx /etc/asterisk 2>/dev/null || true
chmod 2775 /etc/asterisk /var/spool/asterisk /var/spool/asterisk/voicemail /var/lib/asterisk/sounds /var/lib/asterisk/moh
setfacl -R -m u:www-data:rwx,g:asterisk:rwx /etc/asterisk /var/spool/asterisk /var/lib/asterisk 2>/dev/null || true
setfacl -R -d -m u:www-data:rwx,g:asterisk:rwx /etc/asterisk /var/spool/asterisk /var/lib/asterisk 2>/dev/null || true
# Rechargement Asterisk immédiat pour appliquer AMI et Logger
asterisk -rx "core reload" 2>/dev/null || true
@@ -375,16 +437,22 @@ mkdir -p /etc/nginx/sites-available /etc/nginx/sites-enabled
rm -f /etc/nginx/sites-enabled/default
ln -sf /etc/nginx/sites-available/nexus-pbx /etc/nginx/sites-enabled/nexus-pbx
# Application de la configuration Nginx complète (Ports 80, 8080, 8443)
# Application de la configuration Nginx complète (Ports Admin 80/8080/8443 + VHosts UCP isolés 8444/8084)
php -r '
require_once "/var/www/nexus-pbx/src/Database.php";
require_once "/var/www/nexus-pbx/src/FirewallManager.php";
$db = App\Database::getConnection();
$st = $db->query("SELECT admin_https_port, certbot_strict_port80 FROM settings WHERE id = 1")->fetch(PDO::FETCH_ASSOC);
App\FirewallManager::applyNginxConfig((int)($st["admin_https_port"] ?? 8443), (int)($st["certbot_strict_port80"] ?? 0));
$st = App\FirewallManager::getSettings();
App\FirewallManager::ensureUcpFail2banArtifacts();
App\FirewallManager::applyNginxConfig(
(int)($st["admin_https_port"] ?? 8443),
(int)($st["certbot_strict_port80"] ?? 0),
(int)($st["ucp_enabled"] ?? 1),
(int)($st["ucp_https_port"] ?? 8444),
(int)($st["ucp_http_port"] ?? 8084)
);
'
# Filtre Fail2ban pour l'interface Nexus-Web
# Filtre Fail2ban pour l'interface Nexus-Web et Portail UCP
cat << 'EOF' > /etc/fail2ban/filter.d/nexus-web.conf
[Definition]
failregex = ^.*\[NEXUS-WEB-AUTH-FAIL\]:\s*<HOST>.*$
@@ -404,7 +472,12 @@ App\FirewallManager::generateJailLocal(
(int)$st["fail2ban_sip_bantime"],
(int)$st["fail2ban_web_maxretry"],
(int)$st["fail2ban_web_bantime"],
(int)$st["admin_https_port"]
(int)$st["admin_https_port"],
(int)($st["ucp_enabled"] ?? 1),
(int)($st["ucp_https_port"] ?? 8444),
(int)($st["ucp_http_port"] ?? 8084),
(int)($st["fail2ban_ucp_maxretry"] ?? 3),
(int)($st["fail2ban_ucp_bantime"] ?? 3600)
);
'
@@ -427,11 +500,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
@@ -446,6 +536,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")
+423 -42
View File
@@ -11,6 +11,19 @@ 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';
require_once __DIR__ . '/../src/UcpManager.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 +35,15 @@ use App\CertificateManager;
use App\BackupManager;
use App\FirewallManager;
use App\PhonebookManager;
use App\CdrManager;
use App\ClusterManager;
use App\UcpManager;
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 +52,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'] ?? ''));
@@ -50,10 +98,7 @@ try {
]);
} else {
// Journalisation de sécurité pour Fail2ban (jail nexus-web)
$clientIp = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1';
if (str_contains($clientIp, ',')) {
$clientIp = trim(explode(',', $clientIp)[0]);
}
$clientIp = FirewallManager::resolveClientIp();
$logMsg = date('[Y-m-d H:i:s] ') . "[NEXUS-WEB-AUTH-FAIL]: " . $clientIp . " (username: " . ($username ?: 'vide') . ")\n";
@file_put_contents('/var/log/nexus_security.log', $logMsg, FILE_APPEND | LOCK_EX);
@@ -72,6 +117,7 @@ try {
exit;
}
// Toutes les autres actions requièrent une session authentifiée
if (!Auth::isAuthenticated()) {
http_response_code(401);
@@ -87,6 +133,9 @@ try {
switch ($action) {
case 'get_state':
try {
FirewallManager::ensureUcpDeployed();
} catch (\Throwable) {}
$settings = $db->query('SELECT * FROM settings WHERE id = 1')->fetch();
$extensions = $db->query('SELECT * FROM extensions ORDER BY CAST(extension AS INTEGER) ASC, extension ASC')->fetchAll();
$groups = $db->query('SELECT * FROM groups ORDER BY CAST(extension AS INTEGER) ASC, extension ASC')->fetchAll();
@@ -136,8 +185,15 @@ try {
$settings['local_networks'] = [$settings['local_net']];
}
$appDir = dirname(__DIR__);
$installedVerData = file_exists("{$appDir}/version.json")
? json_decode((string)file_get_contents("{$appDir}/version.json"), true)
: [];
$installedVer = $installedVerData['version'] ?? '1.5.0';
echo json_encode([
'success' => true,
'version' => $installedVer,
'user' => Auth::getUsername(),
'settings' => $settings,
'extensions' => $extensions,
@@ -156,6 +212,7 @@ try {
'backups_count' => count(BackupManager::listBackups($db)),
'phonebook_contacts' => PhonebookManager::getContacts($db),
'phonebook_groups' => PhonebookManager::getGroups($db),
'ucp_users' => UcpManager::listUsers($db),
'metrics' => SystemMetrics::collect($db),
'previews' => [
'pjsip' => $generator->generatePjsipConf(),
@@ -219,8 +276,15 @@ try {
@chgrp($webLog, 'asterisk');
@chmod($webLog, 0664);
}
$ucpLog = FirewallManager::UCP_SECURITY_LOG;
if (file_exists($ucpLog)) {
@file_put_contents($ucpLog, '');
@chown($ucpLog, 'www-data');
@chgrp($ucpLog, 'asterisk');
@chmod($ucpLog, 0664);
}
@unlink('/tmp/nexus_fw_status_cache.json');
exec('sudo /usr/bin/fail2ban-client reload 2>&1');
exec('sudo /usr/bin/systemctl restart fail2ban 2>&1 || sudo /usr/bin/fail2ban-client reload 2>&1');
echo json_encode([
'success' => true,
@@ -247,6 +311,7 @@ try {
$voicemail_attach = isset($payload['voicemail_attach']) ? (int)!empty($payload['voicemail_attach']) : 1;
$voicemail_delete = isset($payload['voicemail_delete']) ? (int)!empty($payload['voicemail_delete']) : 0;
$ring_time = max(5, min(120, (int)($payload['ring_time'] ?? 20)));
$groundwire_enabled = !empty($payload['groundwire_enabled']) ? 1 : 0;
if ($force_pin_change === 1) {
$secFile = "/var/spool/asterisk/voicemail/default/{$extension}/secret.conf";
@@ -275,7 +340,7 @@ try {
encryption = :encryption, codecs = :codecs, max_contacts = :max_contacts, enabled = :enabled,
voicemail_enabled = :vme, voicemail_pin = :vmp, force_pin_change = :fpc,
voicemail_skip_pin = :vsp, voicemail_email = :vme_mail, voicemail_attach = :vma,
voicemail_delete = :vmd, ring_time = :rt
voicemail_delete = :vmd, ring_time = :rt, groundwire_enabled = :gwe
WHERE id = :id
');
$stmt->execute([
@@ -296,14 +361,15 @@ try {
':vma' => $voicemail_attach,
':vmd' => $voicemail_delete,
':rt' => $ring_time,
':gwe' => $groundwire_enabled,
]);
} else {
$stmt = $db->prepare('
INSERT INTO extensions (extension, name, secret, transport, encryption, codecs, max_contacts, enabled,
voicemail_enabled, voicemail_pin, force_pin_change, voicemail_skip_pin,
voicemail_email, voicemail_attach, voicemail_delete, ring_time)
voicemail_email, voicemail_attach, voicemail_delete, ring_time, groundwire_enabled)
VALUES (:ext, :name, :secret, :transport, :encryption, :codecs, :max_contacts, :enabled,
:vme, :vmp, :fpc, :vsp, :vme_mail, :vma, :vmd, :rt)
:vme, :vmp, :fpc, :vsp, :vme_mail, :vma, :vmd, :rt, :gwe)
ON CONFLICT(extension) DO UPDATE SET
name = excluded.name,
secret = excluded.secret,
@@ -319,7 +385,8 @@ try {
voicemail_email = excluded.voicemail_email,
voicemail_attach = excluded.voicemail_attach,
voicemail_delete = excluded.voicemail_delete,
ring_time = excluded.ring_time
ring_time = excluded.ring_time,
groundwire_enabled = excluded.groundwire_enabled
');
$stmt->execute([
':ext' => $extension,
@@ -338,6 +405,7 @@ try {
':vma' => $voicemail_attach,
':vmd' => $voicemail_delete,
':rt' => $ring_time,
':gwe' => $groundwire_enabled,
]);
}
@@ -362,6 +430,13 @@ try {
// Synchronisation automatique de l'annuaire d'entreprise
PhonebookManager::syncExtensions($db);
// Régénération automatique des fichiers Asterisk et rechargement PJSIP/Dialplan en direct
$generator->writeAllConfigs();
try {
$ami = AmiClient::fromDatabase($db);
$ami->reloadPjsipAndDialplan();
} catch (\Throwable $e) {}
$modeBadge = $transport === 'tls' ? 'Distant (TLS + SRTP)' : ($encryption ? 'Interne (UDP + SRTP)' : 'Interne (UDP)');
echo json_encode([
'success' => true,
@@ -374,12 +449,100 @@ try {
$stmt = $db->prepare('DELETE FROM extensions WHERE id = :id');
$stmt->execute([':id' => $id]);
PhonebookManager::syncExtensions($db);
$generator->writeAllConfigs();
try {
$ami = AmiClient::fromDatabase($db);
$ami->reloadPjsipAndDialplan();
} catch (\Throwable $e) {}
echo json_encode([
'success' => true,
'message' => 'Poste téléphonique supprimé de la base SQLite.',
]);
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'] ?? ''));
@@ -395,6 +558,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('
@@ -433,6 +604,12 @@ try {
]);
}
$generator->writeAllConfigs();
try {
$ami = AmiClient::fromDatabase($db);
$ami->reloadPjsipAndDialplan();
} catch (\Throwable $e) {}
echo json_encode([
'success' => true,
'message' => "Groupe d'appel {$extension} ({$name}) sauvegardé.",
@@ -442,6 +619,12 @@ try {
case 'delete_group':
$id = (int)($payload['id'] ?? 0);
$db->prepare('DELETE FROM groups WHERE id = :id')->execute([':id' => $id]);
$generator->writeAllConfigs();
try {
$ami = AmiClient::fromDatabase($db);
$ami->reloadPjsipAndDialplan();
} catch (\Throwable $e) {}
echo json_encode([
'success' => true,
'message' => "Groupe de sonnerie supprimé.",
@@ -533,6 +716,12 @@ try {
]);
}
$generator->writeAllConfigs();
try {
$ami = AmiClient::fromDatabase($db);
$ami->reloadPjsipAndDialplan();
} catch (\Throwable $e) {}
echo json_encode([
'success' => true,
'message' => "Groupe de téléavertissement {$extension} ({$name}) sauvegardé.",
@@ -542,6 +731,12 @@ try {
case 'delete_paging_group':
$id = (int)($payload['id'] ?? 0);
$db->prepare('DELETE FROM paging_groups WHERE id = :id')->execute([':id' => $id]);
$generator->writeAllConfigs();
try {
$ami = AmiClient::fromDatabase($db);
$ami->reloadPjsipAndDialplan();
} catch (\Throwable $e) {}
echo json_encode([
'success' => true,
'message' => "Groupe de téléavertissement supprimé.",
@@ -1108,6 +1303,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));
@@ -1181,6 +1377,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,
@@ -1222,7 +1419,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,
@@ -1244,7 +1441,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,
@@ -1254,7 +1451,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,
@@ -1271,7 +1468,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,
@@ -1605,30 +1802,53 @@ try {
case 'save_company_branding':
$company_name = trim((string)($payload['company_name'] ?? ''));
$company_logo = trim((string)($payload['company_logo'] ?? ''));
$ucp_logo = trim((string)($payload['ucp_logo'] ?? ''));
if ($company_logo !== '' && !preg_match('/^data:image\/(png|jpeg|jpg|webp|svg\+xml|gif);base64,/i', $company_logo)) {
foreach (['company_logo' => $company_logo, 'ucp_logo' => $ucp_logo] as $fieldKey => $logoVal) {
if (!array_key_exists($fieldKey, $payload)) {
continue;
}
if ($logoVal !== '' && !preg_match('/^data:image\/(png|jpeg|jpg|webp|svg\+xml|gif);base64,/i', $logoVal)) {
throw new InvalidArgumentException("Format d'image invalide (utilisez PNG, SVG, JPG ou WebP).");
}
if (strlen($company_logo) > 2 * 1024 * 1024) {
if (strlen($logoVal) > 2 * 1024 * 1024) {
throw new InvalidArgumentException("Le logo est trop volumineux (max 1.5 Mo).");
}
}
if (array_key_exists('company_name', $payload) && array_key_exists('company_logo', $payload)) {
$stmt = $db->prepare('UPDATE settings SET company_name = :cn, company_logo = :cl, updated_at = CURRENT_TIMESTAMP WHERE id = 1');
$stmt->execute([':cn' => $company_name, ':cl' => $company_logo]);
} elseif (array_key_exists('company_logo', $payload)) {
$stmt = $db->prepare('UPDATE settings SET company_logo = :cl, updated_at = CURRENT_TIMESTAMP WHERE id = 1');
$stmt->execute([':cl' => $company_logo]);
} elseif (array_key_exists('company_name', $payload)) {
$stmt = $db->prepare('UPDATE settings SET company_name = :cn, updated_at = CURRENT_TIMESTAMP WHERE id = 1');
$stmt->execute([':cn' => $company_name]);
$sets = [];
$params = [];
if (array_key_exists('company_name', $payload)) {
$sets[] = 'company_name = :cn';
$params[':cn'] = $company_name;
}
if (array_key_exists('company_logo', $payload)) {
$sets[] = 'company_logo = :cl';
$params[':cl'] = $company_logo;
}
if (array_key_exists('ucp_logo', $payload)) {
$sets[] = 'ucp_logo = :ul';
$params[':ul'] = $ucp_logo;
}
if ($sets) {
$sets[] = 'updated_at = CURRENT_TIMESTAMP';
$stmt = $db->prepare('UPDATE settings SET ' . implode(', ', $sets) . ' WHERE id = 1');
$stmt->execute($params);
}
$msg = 'Logo et identité entreprise mis à jour sur NexusPBX !';
if (array_key_exists('ucp_logo', $payload) && !array_key_exists('company_logo', $payload) && !array_key_exists('company_name', $payload)) {
$msg = $ucp_logo === ''
? 'Logo UCP réinitialisé (hérite du logo principal).'
: 'Logo spécifique UCP mis à jour !';
} elseif (array_key_exists('company_logo', $payload) && $company_logo === '' && !array_key_exists('company_name', $payload) && !array_key_exists('ucp_logo', $payload)) {
$msg = 'Logo entreprise réinitialisé.';
}
echo json_encode([
'success' => true,
'message' => $company_logo === '' && !array_key_exists('company_name', $payload)
? 'Logo entreprise réinitialisé.'
: 'Logo et identité entreprise mis à jour sur NexusPBX !',
'message' => $msg,
]);
break;
@@ -1689,6 +1909,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'];
@@ -1722,12 +1950,38 @@ try {
$ami = AmiClient::fromDatabase($db);
$reloadInfo = $ami->reloadPjsipAndDialplan();
// S'assurer que le VHost UCP & Fail2ban UCP sont déployés même après un git pull manuel
try {
FirewallManager::ensureUcpDeployed();
} catch (\Throwable) {}
// 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;
@@ -1923,6 +2177,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;
@@ -1957,6 +2212,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
@@ -1976,6 +2232,7 @@ try {
':rtp_checksums' => $rtpChecksums,
':rtp_strict' => $rtpStrict,
':rtp_timeout' => $rtpTimeout,
':rtp_timeout_hold' => $rtpHoldTimeout,
':rtp_keepalive' => $rtpKeepalive,
':anon' => $allowAnonymousSip,
]);
@@ -2231,25 +2488,103 @@ try {
echo json_encode($res);
break;
case 'get_ucp_users':
try {
FirewallManager::ensureUcpDeployed();
} catch (\Throwable) {}
$users = UcpManager::listUsers($db);
echo json_encode([
'success' => true,
'users' => $users,
'ucp_users' => $users,
]);
break;
case 'save_ucp_user':
$users = UcpManager::saveUser($db, $payload);
echo json_encode([
'success' => true,
'users' => $users,
'ucp_users' => $users,
'message' => 'Compte utilisateur UCP enregistré avec succès.',
]);
break;
case 'delete_ucp_user':
$uid = (int)($payload['id'] ?? 0);
$users = UcpManager::deleteUser($db, $uid);
echo json_encode([
'success' => true,
'users' => $users,
'ucp_users' => $users,
'message' => 'Compte utilisateur UCP supprimé.',
]);
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");
$localVerJson = file_exists("{$appDir}/version.json")
? json_decode((string)file_get_contents("{$appDir}/version.json"), true)
: ['version' => '1.2.0', 'changelog' => []];
: ['version' => '1.3.8', 'changelog' => []];
$currentVer = $localVerJson['version'] ?? '1.2.0';
$currentVer = $localVerJson['version'] ?? '1.3.8';
$remoteVer = $currentVer;
$remoteChangelog = [];
$remoteDate = '';
// 1. Interrogation directe via HTTP Raw (indépendant de .git et ultra-rapide)
$rawHttp = @shell_exec("curl -k -s --connect-timeout 4 \"https://git.sorcier-research.com/sorcier/NexusPBX/raw/branch/main/version.json\" 2>/dev/null");
// 1. Interrogation directe via HTTP Raw avec cache-busting strict (bypass OpenResty/Cloudflare max-age)
$ts = time();
$rawHttp = @shell_exec("curl -k -s -H 'Cache-Control: no-cache, no-store' -H 'Pragma: no-cache' --connect-timeout 4 \"https://git.sorcier-research.com/sorcier/NexusPBX/raw/branch/main/version.json?_t={$ts}\" 2>/dev/null");
if (empty($rawHttp)) {
$ctx = stream_context_create([
'http' => ['timeout' => 4],
'http' => [
'timeout' => 4,
'header' => "Cache-Control: no-cache\r\nPragma: no-cache\r\n"
],
'ssl' => ['verify_peer' => false, 'verify_peer_name' => false]
]);
$rawHttp = @file_get_contents("https://git.sorcier-research.com/sorcier/NexusPBX/raw/branch/main/version.json", false, $ctx);
$rawHttp = @file_get_contents("https://git.sorcier-research.com/sorcier/NexusPBX/raw/branch/main/version.json?_t={$ts}", false, $ctx);
}
if (!empty($rawHttp)) {
@@ -2274,11 +2609,6 @@ try {
}
$hasUpdate = version_compare($remoteVer, $currentVer, '>');
$localHash = trim((string)shell_exec("git --git-dir={$appDir}/.git rev-parse HEAD 2>/dev/null"));
$remoteHash = trim((string)shell_exec("git --git-dir={$appDir}/.git rev-parse origin/main 2>/dev/null"));
if (!$hasUpdate && $localHash !== '' && $remoteHash !== '' && $localHash !== $remoteHash) {
$hasUpdate = true;
}
echo json_encode([
'success' => true,
@@ -2291,22 +2621,73 @@ try {
break;
case 'run_update':
@set_time_limit(300);
$output = [];
$code = 0;
exec("sudo /var/www/nexus-pbx/scripts/update.sh 2>&1", $output, $code);
$outputStr = implode("\n", $output);
$appDir = dirname(__DIR__);
clearstatcache(true, "{$appDir}/version.json");
if (function_exists('opcache_reset')) {
@opcache_reset();
}
$newVerJson = file_exists("{$appDir}/version.json")
? json_decode((string)file_get_contents("{$appDir}/version.json"), true)
: ['version' => '1.3.0'];
: ['version' => '1.4.3'];
while (ob_get_level() > 0) {
@ob_end_clean();
}
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-cache, no-store, must-revalidate');
echo json_encode([
'success' => ($code === 0),
'new_version' => $newVerJson['version'] ?? '1.3.0',
'new_version' => $newVerJson['version'] ?? '1.4.3',
'output' => $outputStr,
'message' => ($code === 0) ? "Mise à jour réussie !" : "Erreur lors de la mise à jour",
]);
if (function_exists('fastcgi_finish_request')) {
fastcgi_finish_request();
}
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:
+1553 -101
View File
File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 795 KiB

+949 -63
View File
File diff suppressed because it is too large. Load diff
+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
-153
View File
@@ -1,153 +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.custom.conf \
/etc/asterisk/voicemail.custom.conf \
/etc/asterisk/rtp.conf \
/etc/asterisk/features.conf 2>/dev/null || true
mkdir -p /var/lib/asterisk/sounds/custom /var/lib/asterisk/moh /var/spool/asterisk/voicemail 2>/dev/null || true
chown -R asterisk:asterisk /etc/asterisk /var/lib/asterisk /var/spool/asterisk /var/log/asterisk 2>/dev/null || true
chmod -R 775 /etc/asterisk /var/lib/asterisk/sounds /var/lib/asterisk/moh /var/spool/asterisk 2>/dev/null || true
chmod 2775 /etc/asterisk /var/lib/asterisk/sounds /var/lib/asterisk/sounds/custom /var/lib/asterisk/moh /var/spool/asterisk 2>/dev/null || true
chmod 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
+12 -1
View File
@@ -19,6 +19,8 @@ switch ($cmd) {
echo "• Pare-feu & Filtrage : " . ($settings['firewall_enabled'] ? "\033[32mACTIF\033[0m" : "\033[33mINACTIF\033[0m") . "\n";
echo "• Mode Strict Certbot 80 : " . ($settings['certbot_strict_port80'] ? "OUI (Port 80 ACME seul)" : "NON (Port 80 ouvert)") . "\n";
echo "• Port HTTPS Admin : " . ($settings['admin_https_port'] ?? 8443) . "\n";
$ucpSt = !empty($settings['ucp_enabled']) ? "\033[32mACTIF\033[0m (HTTPS:" . ($settings['ucp_https_port'] ?? 8444) . " / HTTP:" . ($settings['ucp_http_port'] ?? 8084) . ")" : "\033[33mINACTIF\033[0m";
echo "• Portail Utilisateur UCP: " . $ucpSt . "\n";
echo "• Total IPs Bannies : " . $st['total_banned'] . "\n";
if (!empty($st['banned_ips'])) {
echo "\nListe des IPs bannies :\n";
@@ -72,8 +74,17 @@ switch ($cmd) {
echo ($res['success'] ? "\033[32m[OK]\033[0m " : "\033[31m[ERREUR]\033[0m ") . $res['message'] . "\n";
break;
case 'init-ucp':
case 'sync-ucp':
case 'apply':
$settings = FirewallManager::getSettings();
$res = FirewallManager::saveSettings($settings);
@unlink('/tmp/nexus_fw_status_cache.json');
echo ($res['success'] ? "\033[32m[OK]\033[0m " : "\033[31m[ERREUR]\033[0m ") . ($res['message'] ?? '') . "\n";
break;
default:
echo "Usage: nexus-firewall [status|on|off|ban <ip>|unban <ip>]\n";
echo "Usage: nexus-firewall [status|on|off|init-ucp|ban <ip>|unban <ip>]\n";
exit(1);
}
+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
+154 -25
View File
@@ -81,66 +81,188 @@ 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}"
# Nettoyage préventif d'éventuels anciens dossiers temporaires HA dans /tmp
find /tmp -maxdepth 1 \( -name 'nexus_cluster_extract_*' -o -name 'nexus_cluster_tar_*' -o -name 'nexus_cluster_recv_*' -o -name 'nexus_cluster_sync_*' -o -name 'nexus_cluster_db_*' \) -mmin +10 -exec rm -rf {} + 2>/dev/null || true
# 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 "${GIT_REMOTE}/${GIT_BRANCH}"
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, /usr/sbin/ufw, /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
# 3. Vérification des dépendances Debian & extensions PHP
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 || {
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
}
if ! which sqlite3 >/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; then
echo -e " -> Installation de paquets système complémentaires..."
(apt-get update -y -qq && apt-get install -y -qq --no-install-recommends sqlite3 acl ffmpeg rsync sox sshpass keepalived iputils-arping) 2>/dev/null || true
fi
# 4. Exécution des migrations SQLite et régénération Dialplan
echo -e "${C_GRAY}[4/6] Exécution des migrations de base de données & régénération dialplan...${C_RESET}"
PHP_MODS=$(php -m 2>/dev/null || true)
if ! echo "$PHP_MODS" | grep -qi "^sqlite3$" || ! echo "$PHP_MODS" | grep -qi "^curl$" || ! echo "$PHP_MODS" | grep -qi "^xml$" || ! echo "$PHP_MODS" | grep -qi "^mbstring$" || ! echo "$PHP_MODS" | grep -qi "^zip$"; then
echo -e " -> Installation des extensions PHP manquantes..."
(apt-get update -y -qq && apt-get install -y -qq --no-install-recommends php-sqlite3 php-curl php-xml php-mbstring php-zip) 2>/dev/null || true
PHP_FPM_SVC=$(systemctl list-unit-files --type=service --no-legend 2>/dev/null | awk '/php.*-fpm\.service/ {print $1; exit}')
[ -n "$PHP_FPM_SVC" ] && systemctl restart "$PHP_FPM_SVC" >/dev/null 2>&1 || true
fi
# 4. Exécution des migrations SQLite, régénération Dialplan, Nginx & Fail2ban UCP
echo -e "${C_GRAY}[4/6] Exécution des migrations de base de données & régénération dialplan/web...${C_RESET}"
if [ -w /var/log ]; then
touch /var/log/nexus_ucp_security.log 2>/dev/null || true
chown www-data:asterisk /var/log/nexus_ucp_security.log 2>/dev/null || true
chmod 0664 /var/log/nexus_ucp_security.log 2>/dev/null || true
fi
[ -d "$APP_DIR/ucp" ] && chmod 0755 "$APP_DIR/ucp" "$APP_DIR/ucp/assets" 2>/dev/null || true
php -r '
require_once "/var/www/nexus-pbx/src/Database.php";
require_once "/var/www/nexus-pbx/src/ConfigGenerator.php";
require_once "/var/www/nexus-pbx/src/FirewallManager.php";
$db = App\Database::getConnection(true);
$gen = new App\ConfigGenerator($db);
$gen->writeAllConfigs();
try {
$st = App\FirewallManager::getSettings();
App\FirewallManager::ensureUcpFail2banArtifacts();
App\FirewallManager::applyNginxConfig(
(int)($st["admin_https_port"] ?? 8443),
(int)($st["certbot_strict_port80"] ?? 0),
(int)($st["ucp_enabled"] ?? 1),
(int)($st["ucp_https_port"] ?? 8444),
(int)($st["ucp_http_port"] ?? 8084)
);
App\FirewallManager::generateJailLocal(
(string)($st["fail2ban_whitelist_ips"] ?? ""),
(int)($st["fail2ban_sip_maxretry"] ?? 3),
(int)($st["fail2ban_sip_findtime"] ?? 600),
(int)($st["fail2ban_sip_bantime"] ?? 3600),
(int)($st["fail2ban_web_maxretry"] ?? 3),
(int)($st["fail2ban_web_bantime"] ?? 3600),
(int)($st["admin_https_port"] ?? 8443),
(int)($st["ucp_enabled"] ?? 1),
(int)($st["ucp_https_port"] ?? 8444),
(int)($st["ucp_http_port"] ?? 8084),
(int)($st["fail2ban_ucp_maxretry"] ?? 3),
(int)($st["fail2ban_ucp_bantime"] ?? 3600)
);
} catch (\Throwable $e) {
fwrite(STDERR, "Warning UCP/Firewall update: " . $e->getMessage() . "\n");
}
'
# 5. Fix permissions & ACLs
echo -e "${C_GRAY}[5/6] Application des permissions et ACLs système...${C_RESET}"
setfacl -R -m u:www-data:rwx,g:asterisk:rwx /etc/asterisk /var/lib/asterisk/sounds /var/lib/asterisk/moh 2>/dev/null || true
setfacl -R -d -m u:www-data:rwx,g:asterisk:rwx /etc/asterisk /var/lib/asterisk/sounds /var/lib/asterisk/moh 2>/dev/null || true
php -r '
require_once "/var/www/nexus-pbx/src/Database.php";
require_once "/var/www/nexus-pbx/src/SystemChecker.php";
App\SystemChecker::ensureAsteriskIncludes();
' 2>/dev/null || true
mkdir -p /var/spool/asterisk/voicemail/default /var/lib/asterisk/sounds/custom /var/lib/asterisk/moh 2>/dev/null || true
for lang in fr en es; do
if [ -d "/usr/share/asterisk/sounds/$lang" ] && [ ! -e "/var/lib/asterisk/sounds/$lang" ]; then
ln -sf "/usr/share/asterisk/sounds/$lang" "/var/lib/asterisk/sounds/$lang" 2>/dev/null || true
fi
if [ -d "/var/lib/asterisk/sounds/$lang" ] && [ ! -e "/var/lib/asterisk/sounds/$lang/custom" ]; then
ln -sf /var/lib/asterisk/sounds/custom "/var/lib/asterisk/sounds/$lang/custom" 2>/dev/null || true
fi
done
if [ ! -e "/usr/share/asterisk/sounds/custom" ]; then
ln -sf /var/lib/asterisk/sounds/custom /usr/share/asterisk/sounds/custom 2>/dev/null || true
fi
chown -R asterisk:asterisk /var/spool/asterisk /var/lib/asterisk/sounds /var/lib/asterisk/moh /var/log/asterisk 2>/dev/null || true
chmod 2775 /var/spool/asterisk /var/spool/asterisk/voicemail /var/lib/asterisk/sounds /var/lib/asterisk/moh /var/log/asterisk /var/log/asterisk/cdr-csv 2>/dev/null || true
mkdir -p /var/log/asterisk/cdr-csv 2>/dev/null || true
touch /var/log/asterisk/cdr-csv/Master.csv 2>/dev/null || true
chmod 0664 /var/log/asterisk/cdr-csv/Master.csv 2>/dev/null || true
setfacl -R -m u:www-data:rwx,g:asterisk:rwx /etc/asterisk /var/lib/asterisk/sounds /var/lib/asterisk/moh /var/spool/asterisk /var/log/asterisk 2>/dev/null || true
setfacl -R -d -m u:www-data:rwx,g:asterisk:rwx /etc/asterisk /var/lib/asterisk/sounds /var/lib/asterisk/moh /var/spool/asterisk /var/log/asterisk 2>/dev/null || true
chmod 0664 /etc/asterisk/*.conf /etc/asterisk/*.custom.conf 2>/dev/null || true
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
# 6. Rechargement des services
echo -e "${C_GRAY}[6/6] Rechargement à chaud d'Asterisk et des services web...${C_RESET}"
asterisk -rx "core reload" >/dev/null 2>&1 || true
asterisk -rx "pjsip reload" >/dev/null 2>&1 || true
asterisk -rx "dialplan reload" >/dev/null 2>&1 || true
systemctl reload php*-fpm 2>/dev/null || true
# Rechargement des services web : différé si exécuté depuis l'interface web pour éviter de couper la requête HTTP
if [ -t 1 ]; then
systemctl reload php8.4-fpm 2>/dev/null || systemctl reload php8.2-fpm 2>/dev/null || systemctl reload php*-fpm 2>/dev/null || true
systemctl reload nginx 2>/dev/null || true
fail2ban-client reload >/dev/null 2>&1 || true
else
(nohup bash -c "sleep 1 && (systemctl reload php8.4-fpm 2>/dev/null || systemctl reload php8.2-fpm 2>/dev/null || systemctl reload php*-fpm 2>/dev/null || true); systemctl reload nginx 2>/dev/null || true; fail2ban-client reload >/dev/null 2>&1 || true" </dev/null >/dev/null 2>&1 &)
fi
NEW_VER="Inconnue"
if [ -f "$APP_DIR/version.json" ]; then
@@ -157,11 +279,18 @@ echo ""
if [ -f "$APP_DIR/version.json" ]; then
echo -e "${C_CYAN}${C_BOLD}Nouveautés de cette version :${C_RESET}"
php -r '
$v = json_decode(file_get_contents("/var/www/nexus-pbx/version.json"), true);
if (!empty($v["changelog"])) {
foreach ($v["changelog"] as $line) {
$v = json_decode(@file_get_contents("/var/www/nexus-pbx/version.json"), true);
if (!empty($v["changelog"]) && is_array($v["changelog"])) {
$first = $v["changelog"][0];
if (is_array($first) && !empty($first["changes"])) {
foreach ($first["changes"] as $line) {
echo " • " . $line . "\n";
}
} else {
foreach ($v["changelog"] as $line) {
if (is_string($line)) echo " • " . $line . "\n";
}
}
}
'
fi
+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
+259 -28
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()";
@@ -905,9 +987,9 @@ class ConfigGenerator
$lines[] = " same => n,Hangup()";
}
$lines[] = " same => n(cf_{$num}),NoOp(Poste {$num} renvoi inconditionnel vers \${DB(CF/{$num})})";
$lines[] = " same => n,Goto(from-internal,\${DB(CF/{$num})},1)";
$lines[] = " same => n,GotoIf(\$[\"\${DB(CF/{$num}):0:3}\" = \"vm-\"]?app-voicemail,\${DB(CF/{$num}):3},1:from-internal,\${DB(CF/{$num})},1)";
$lines[] = " same => n(cfu_{$num}),NoOp(Poste {$num} indisponible, renvoi vers \${DB(CFU/{$num})})";
$lines[] = " same => n,Goto(from-internal,\${DB(CFU/{$num})},1)";
$lines[] = " same => n,GotoIf(\$[\"\${DB(CFU/{$num}):0:3}\" = \"vm-\"]?app-voicemail,\${DB(CFU/{$num}):3},1:from-internal,\${DB(CFU/{$num})},1)";
$lines[] = "";
}
@@ -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;
}
}
+125 -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,19 @@ 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 ucp_https_port, fail2ban_ucp_maxretry, groundwire_secret, ucp_logo FROM settings LIMIT 1");
self::$instance->query("SELECT groundwire_enabled FROM extensions LIMIT 1");
self::$instance->query("SELECT id FROM ucp_users LIMIT 1");
} catch (\Throwable $e) {
self::initializeSchema();
}
}
}
@@ -82,6 +91,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,
@@ -108,6 +118,7 @@ class Database
encryption INTEGER NOT NULL DEFAULT 0 CHECK (encryption IN (0, 1, 2)),
codecs VARCHAR(64) NOT NULL DEFAULT 'alaw,ulaw,opus',
max_contacts INTEGER NOT NULL DEFAULT 2,
groundwire_enabled INTEGER NOT NULL DEFAULT 0 CHECK (groundwire_enabled IN (0, 1)),
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
@@ -150,6 +161,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 +318,44 @@ 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
);
-- 15. Table ucp_users : Utilisateurs du portail UCP (isolé de l'Admin)
CREATE TABLE IF NOT EXISTS ucp_users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username VARCHAR(64) NOT NULL UNIQUE,
display_name VARCHAR(128) NOT NULL DEFAULT '',
email VARCHAR(128) NOT NULL DEFAULT '',
password_hash TEXT NOT NULL,
extensions_json TEXT NOT NULL DEFAULT '[]',
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
last_login_at DATETIME DEFAULT NULL,
last_login_ip VARCHAR(64) NOT NULL DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
SQL;
$db->exec($schema);
@@ -321,6 +371,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();
@@ -344,6 +395,9 @@ class Database
if (!in_array('company_logo', $existing, true)) {
$db->exec("ALTER TABLE settings ADD COLUMN company_logo TEXT NOT NULL DEFAULT ''");
}
if (!in_array('ucp_logo', $existing, true)) {
$db->exec("ALTER TABLE settings ADD COLUMN ucp_logo TEXT NOT NULL DEFAULT ''");
}
if (!in_array('default_moh_sound_id', $existing, true)) {
$db->exec("ALTER TABLE settings ADD COLUMN default_moh_sound_id INTEGER DEFAULT 2");
}
@@ -360,6 +414,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'",
@@ -384,12 +439,25 @@ class Database
'fail2ban_web_maxretry' => "INTEGER NOT NULL DEFAULT 3",
'fail2ban_web_bantime' => "INTEGER NOT NULL DEFAULT 3600",
'fail2ban_whitelist_ips' => "TEXT NOT NULL DEFAULT '127.0.0.1/8 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 82.122.84.60'",
'ucp_enabled' => "INTEGER NOT NULL DEFAULT 1",
'ucp_https_port' => "INTEGER NOT NULL DEFAULT 8444",
'ucp_http_port' => "INTEGER NOT NULL DEFAULT 8084",
'fail2ban_ucp_maxretry' => "INTEGER NOT NULL DEFAULT 3",
'fail2ban_ucp_bantime' => "INTEGER NOT NULL DEFAULT 3600",
'groundwire_secret' => "VARCHAR(128) NOT NULL DEFAULT ''",
];
foreach ($extraSettings as $colName => $colDef) {
if (!in_array($colName, $existing, true)) {
$db->exec("ALTER TABLE settings ADD COLUMN {$colName} {$colDef}");
}
}
// Clé HMAC persistante en base (partagée entre nœuds HA) pour les liens XML GroundWire
$gwSecret = (string)($db->query('SELECT groundwire_secret FROM settings WHERE id = 1')->fetchColumn() ?: '');
if ($gwSecret === '') {
$newSecret = bin2hex(random_bytes(32));
$db->prepare('UPDATE settings SET groundwire_secret = :s WHERE id = 1')->execute([':s' => $newSecret]);
}
}
private static function migrateExtensionsTable(PDO $db): void
@@ -432,6 +500,7 @@ class Database
'voicemail_attach' => "INTEGER NOT NULL DEFAULT 1",
'voicemail_delete' => "INTEGER NOT NULL DEFAULT 0",
'ring_time' => "INTEGER NOT NULL DEFAULT 20",
'groundwire_enabled' => "INTEGER NOT NULL DEFAULT 0",
];
foreach ($extraExtensionCols as $colName => $colDef) {
if (!in_array($colName, $existing, true)) {
@@ -525,6 +594,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 +817,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,
]);
}
}
}
+330 -18
View File
@@ -13,6 +13,9 @@ class FirewallManager
public const JAIL_LOCAL_PATH = '/etc/fail2ban/jail.local';
public const NGINX_CONF_PATH = '/etc/nginx/sites-available/nexus-pbx';
public const WEB_FILTER_PATH = '/etc/fail2ban/filter.d/nexus-web.conf';
public const UCP_FILTER_PATH = '/etc/fail2ban/filter.d/nexus-ucp.conf';
public const UCP_SECURITY_LOG = '/var/log/nexus_ucp_security.log';
public const UCP_ROOT_DIR = '/var/www/nexus-pbx/ucp';
/**
* Récupère la configuration actuelle du pare-feu et de Fail2ban depuis SQLite.
@@ -23,7 +26,9 @@ class FirewallManager
$stmt = $db->query("SELECT
firewall_enabled, admin_https_port, certbot_strict_port80,
fail2ban_sip_maxretry, fail2ban_sip_findtime, fail2ban_sip_bantime,
fail2ban_web_maxretry, fail2ban_web_bantime, fail2ban_whitelist_ips
fail2ban_web_maxretry, fail2ban_web_bantime, fail2ban_whitelist_ips,
ucp_enabled, ucp_https_port, ucp_http_port,
fail2ban_ucp_maxretry, fail2ban_ucp_bantime
FROM settings WHERE id = 1");
$row = $stmt->fetch(PDO::FETCH_ASSOC);
@@ -38,6 +43,11 @@ class FirewallManager
'fail2ban_web_maxretry' => 3,
'fail2ban_web_bantime' => 3600,
'fail2ban_whitelist_ips' => '127.0.0.1/8 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 82.122.84.60',
'ucp_enabled' => 1,
'ucp_https_port' => 8444,
'ucp_http_port' => 8084,
'fail2ban_ucp_maxretry' => 3,
'fail2ban_ucp_bantime' => 3600,
];
}
@@ -51,6 +61,11 @@ class FirewallManager
'fail2ban_web_maxretry' => (int)($row['fail2ban_web_maxretry'] ?? 3),
'fail2ban_web_bantime' => (int)($row['fail2ban_web_bantime'] ?? 3600),
'fail2ban_whitelist_ips' => (string)($row['fail2ban_whitelist_ips'] ?? '127.0.0.1/8 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 82.122.84.60'),
'ucp_enabled' => (int)($row['ucp_enabled'] ?? 1),
'ucp_https_port' => (int)($row['ucp_https_port'] ?? 8444),
'ucp_http_port' => (int)($row['ucp_http_port'] ?? 8084),
'fail2ban_ucp_maxretry' => (int)($row['fail2ban_ucp_maxretry'] ?? 3),
'fail2ban_ucp_bantime' => (int)($row['fail2ban_ucp_bantime'] ?? 3600),
];
}
@@ -111,11 +126,8 @@ class FirewallManager
}
}
// IP actuelle du client
$currentIp = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1';
if (str_contains($currentIp, ',')) {
$currentIp = trim(explode(',', $currentIp)[0]);
}
// IP actuelle du client (avec résolution reverse proxy si proxy privé/local)
$currentIp = self::resolveClientIp();
$status = [
'is_running' => $isRunning,
@@ -151,7 +163,7 @@ class FirewallManager
}
} else {
// Débannir de toutes les prisons connues
foreach (['asterisk', 'nexus-web', 'sshd'] as $j) {
foreach (['asterisk', 'nexus-web', 'nexus-ucp', 'sshd'] as $j) {
$cmd = "timeout 2 sudo -n /usr/bin/fail2ban-client set " . escapeshellarg($j) . " unbanip " . escapeshellarg($ip) . " 2>&1";
exec($cmd, $out, $code);
if ($code === 0) {
@@ -200,6 +212,7 @@ class FirewallManager
{
$db = Database::getConnection();
$oldSettings = self::getSettings();
$strictPort80 = !empty($data['certbot_strict_port80']) ? 1 : 0;
$firewallEnabled = isset($data['firewall_enabled']) ? (!empty($data['firewall_enabled']) ? 1 : 0) : 1;
$adminPort = max(1, min(65535, (int)($data['admin_https_port'] ?? 8443)));
@@ -209,6 +222,26 @@ class FirewallManager
$webMaxRetry = max(1, min(20, (int)($data['fail2ban_web_maxretry'] ?? 3)));
$webBanTime = max(60, min(31536000, (int)($data['fail2ban_web_bantime'] ?? 3600)));
$ucpEnabled = isset($data['ucp_enabled']) ? (!empty($data['ucp_enabled']) ? 1 : 0) : (int)$oldSettings['ucp_enabled'];
$ucpHttpsPort = isset($data['ucp_https_port']) ? max(0, min(65535, (int)$data['ucp_https_port'])) : (int)$oldSettings['ucp_https_port'];
$ucpHttpPort = isset($data['ucp_http_port']) ? max(0, min(65535, (int)$data['ucp_http_port'])) : (int)$oldSettings['ucp_http_port'];
$ucpMaxRetry = isset($data['fail2ban_ucp_maxretry']) ? max(1, min(20, (int)$data['fail2ban_ucp_maxretry'])) : (int)$oldSettings['fail2ban_ucp_maxretry'];
$ucpBanTime = isset($data['fail2ban_ucp_bantime']) ? max(60, min(31536000, (int)$data['fail2ban_ucp_bantime'])) : (int)$oldSettings['fail2ban_ucp_bantime'];
if ($ucpEnabled === 1 && $ucpHttpsPort === 0 && $ucpHttpPort === 0) {
throw new InvalidArgumentException("Lorsque l'UCP est activé, au moins un port (HTTPS ou HTTP) doit être supérieur à 0.");
}
$reservedPorts = [80, 443, 8080, $adminPort, 5060, 5061];
if ($ucpHttpsPort > 0 && in_array($ucpHttpsPort, $reservedPorts, true)) {
throw new InvalidArgumentException("Le port HTTPS UCP ({$ucpHttpsPort}) entre en conflit avec un port réservé (Admin/SIP/ACME).");
}
if ($ucpHttpPort > 0 && in_array($ucpHttpPort, $reservedPorts, true)) {
throw new InvalidArgumentException("Le port HTTP UCP ({$ucpHttpPort}) entre en conflit avec un port réservé (Admin/SIP/ACME).");
}
if ($ucpHttpsPort > 0 && $ucpHttpPort > 0 && $ucpHttpsPort === $ucpHttpPort) {
throw new InvalidArgumentException("Les ports HTTPS et HTTP de l'UCP doivent être différents.");
}
$whitelist = trim((string)($data['fail2ban_whitelist_ips'] ?? ''));
$cleanTokens = array_values(array_filter(preg_split('/\s+/', $whitelist)));
@@ -233,6 +266,11 @@ class FirewallManager
fail2ban_web_maxretry = :wmr,
fail2ban_web_bantime = :wbt,
fail2ban_whitelist_ips = :wl,
ucp_enabled = :ue,
ucp_https_port = :uhs,
ucp_http_port = :uhp,
fail2ban_ucp_maxretry = :umr,
fail2ban_ucp_bantime = :ubt,
updated_at = CURRENT_TIMESTAMP
WHERE id = 1");
@@ -246,9 +284,15 @@ class FirewallManager
':wmr' => $webMaxRetry,
':wbt' => $webBanTime,
':wl' => $finalWhitelist,
':ue' => $ucpEnabled,
':uhs' => $ucpHttpsPort,
':uhp' => $ucpHttpPort,
':umr' => $ucpMaxRetry,
':ubt' => $ucpBanTime,
]);
// 2. Génération et application du fichier /etc/fail2ban/jail.local
// 2. Garantir la présence du filtre & journal Fail2ban UCP puis générer /etc/fail2ban/jail.local
self::ensureUcpFail2banArtifacts();
self::generateJailLocal(
$finalWhitelist,
$sipMaxRetry,
@@ -256,7 +300,12 @@ class FirewallManager
$sipBanTime,
$webMaxRetry,
$webBanTime,
$adminPort
$adminPort,
$ucpEnabled,
$ucpHttpsPort,
$ucpHttpPort,
$ucpMaxRetry,
$ucpBanTime
);
// 3. Rechargement / Redémarrage propre de Fail2ban
@@ -265,14 +314,19 @@ class FirewallManager
exec('timeout 5 sudo -n /usr/bin/systemctl restart fail2ban 2>&1');
}
// 4. Mise à jour de la configuration Nginx
self::applyNginxConfig($adminPort, $strictPort80);
// 4. Mise à jour de la configuration Nginx & ouverture/fermeture dynamique des ports UCP
self::applyNginxConfig($adminPort, $strictPort80, $ucpEnabled, $ucpHttpsPort, $ucpHttpPort);
self::syncUcpFirewallPorts($oldSettings, [
'ucp_enabled' => $ucpEnabled,
'ucp_https_port' => $ucpHttpsPort,
'ucp_http_port' => $ucpHttpPort,
]);
@unlink('/tmp/nexus_fw_status_cache.json');
return [
'success' => true,
'message' => 'Configuration Pare-feu & Fail2ban appliquée avec succès ! Nginx et Fail2ban rechargés.',
'message' => 'Configuration Pare-feu, UCP & Fail2ban appliquée avec succès ! Nginx et Fail2ban rechargés.',
'settings' => self::getSettings(),
];
}
@@ -287,9 +341,30 @@ class FirewallManager
int $sipBanTime,
int $webMaxRetry,
int $webBanTime,
int $adminPort
int $adminPort,
?int $ucpEnabled = null,
?int $ucpHttpsPort = null,
?int $ucpHttpPort = null,
?int $ucpMaxRetry = null,
?int $ucpBanTime = null
): void {
if ($ucpEnabled === null || $ucpHttpsPort === null || $ucpHttpPort === null || $ucpMaxRetry === null || $ucpBanTime === null) {
$st = self::getSettings();
$ucpEnabled = $ucpEnabled ?? $st['ucp_enabled'];
$ucpHttpsPort = $ucpHttpsPort ?? $st['ucp_https_port'];
$ucpHttpPort = $ucpHttpPort ?? $st['ucp_http_port'];
$ucpMaxRetry = $ucpMaxRetry ?? $st['fail2ban_ucp_maxretry'];
$ucpBanTime = $ucpBanTime ?? $st['fail2ban_ucp_bantime'];
}
self::ensureUcpFail2banArtifacts();
$portsWeb = "http,https,{$adminPort}";
$ucpPortsList = [];
if ($ucpHttpsPort > 0) $ucpPortsList[] = (string)$ucpHttpsPort;
if ($ucpHttpPort > 0) $ucpPortsList[] = (string)$ucpHttpPort;
$ucpJailEnabled = ($ucpEnabled === 1 && !empty($ucpPortsList)) ? 'true' : 'false';
$portsUcp = !empty($ucpPortsList) ? implode(',', $ucpPortsList) : '8444';
$conf = <<<EOF
# Fichier généré automatiquement par NexusPBX
@@ -331,6 +406,16 @@ bantime = {$webBanTime}
port = {$portsWeb}
protocol = tcp
[nexus-ucp]
enabled = {$ucpJailEnabled}
filter = nexus-ucp
logpath = /var/log/nexus_ucp_security.log
maxretry = {$ucpMaxRetry}
findtime = 600
bantime = {$ucpBanTime}
port = {$portsUcp}
protocol = tcp
EOF;
file_put_contents(self::JAIL_LOCAL_PATH, $conf);
@@ -339,10 +424,22 @@ EOF;
}
/**
* Met à jour Nginx pour séparer le port 80 (Certbot/ACME uniquement si strict) et écouter sur le port d'admin.
* Met à jour Nginx pour séparer le port 80 (Certbot/ACME), le port d'admin et les VHosts isolés de l'UCP.
*/
public static function applyNginxConfig(int $adminHttpsPort, int $strictPort80): void
{
public static function applyNginxConfig(
int $adminHttpsPort,
int $strictPort80,
?int $ucpEnabled = null,
?int $ucpHttpsPort = null,
?int $ucpHttpPort = null
): void {
if ($ucpEnabled === null || $ucpHttpsPort === null || $ucpHttpPort === null) {
$st = self::getSettings();
$ucpEnabled = $ucpEnabled ?? $st['ucp_enabled'];
$ucpHttpsPort = $ucpHttpsPort ?? $st['ucp_https_port'];
$ucpHttpPort = $ucpHttpPort ?? $st['ucp_http_port'];
}
$phpSock = '/run/php/php8.4-fpm.sock';
if (!file_exists($phpSock)) {
$found = glob('/run/php/php*-fpm.sock');
@@ -485,7 +582,19 @@ server {
}
EOF;
$fullNginxConfig = $port80Block . "\n\n" . $phonebookBlock . "\n\n" . $httpsBlock . "\n";
$blocks = [$port80Block, $phonebookBlock, $httpsBlock];
// VirtualHosts Nginx totalement isolés pour l'UCP (/var/www/nexus-pbx/ucp)
if ($ucpEnabled === 1 && is_dir(self::UCP_ROOT_DIR)) {
if ($ucpHttpsPort > 0) {
$blocks[] = self::buildUcpServerBlock($ucpHttpsPort, true, $phpSock);
}
if ($ucpHttpPort > 0) {
$blocks[] = self::buildUcpServerBlock($ucpHttpPort, false, $phpSock);
}
}
$fullNginxConfig = implode("\n\n", $blocks) . "\n";
file_put_contents(self::NGINX_CONF_PATH, $fullNginxConfig);
@chgrp(self::NGINX_CONF_PATH, 'asterisk');
@chmod(self::NGINX_CONF_PATH, 0664);
@@ -500,6 +609,203 @@ EOF;
exec('sudo -n /usr/bin/systemctl reload nginx 2>&1');
}
private static function buildUcpServerBlock(int $port, bool $ssl, string $phpSock): string
{
$ucpRoot = self::UCP_ROOT_DIR;
$listenDirectives = $ssl
? "listen {$port} ssl;\n listen [::]:{$port} ssl;"
: "listen {$port};\n listen [::]:{$port};";
$sslDirectives = $ssl ? <<<SSL
ssl_certificate /etc/asterisk/keys/active_cert.crt;
ssl_certificate_key /etc/asterisk/keys/active_key.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
SSL : '';
$logPrefix = $ssl ? 'nexus-ucp.ssl' : 'nexus-ucp';
return <<<EOF
server {
{$listenDirectives}
server_name _;{$sslDirectives}
client_max_body_size 16M;
root {$ucpRoot};
index index.php;
access_log /var/log/nginx/{$logPrefix}.access.log;
error_log /var/log/nginx/{$logPrefix}.error.log;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
location / {
try_files \$uri \$uri/ /index.php?\$query_string;
}
location ~* \.(?:css|js)$ {
expires -1;
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
try_files \$uri =404;
}
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:{$phpSock};
fastcgi_param SCRIPT_FILENAME \$document_root\$fastcgi_script_name;
}
location ~ /\. {
deny all;
}
}
EOF;
}
/**
* Crée le filtre et le fichier de log Fail2ban dédiés au portail UCP.
*/
public static function ensureUcpFail2banArtifacts(): void
{
$filterContent = "[Definition]\nfailregex = ^.*\\[NEXUS-UCP-AUTH-FAIL\\]:\\s*<HOST>.*$\nignoreregex =\n";
$isNonRoot = function_exists('posix_geteuid') && posix_geteuid() !== 0;
if (!file_exists(self::UCP_FILTER_PATH) || @file_get_contents(self::UCP_FILTER_PATH) !== $filterContent) {
if (@file_put_contents(self::UCP_FILTER_PATH, $filterContent) !== false) {
@chmod(self::UCP_FILTER_PATH, 0644);
} elseif ($isNonRoot) {
@exec('sudo -n /var/www/nexus-pbx/scripts/nexus-firewall init-ucp >/dev/null 2>&1');
return;
}
}
if (!file_exists(self::UCP_SECURITY_LOG)) {
if (@touch(self::UCP_SECURITY_LOG) === false && $isNonRoot) {
@exec('sudo -n /var/www/nexus-pbx/scripts/nexus-firewall init-ucp >/dev/null 2>&1');
return;
}
}
@chown(self::UCP_SECURITY_LOG, 'www-data');
@chgrp(self::UCP_SECURITY_LOG, 'asterisk');
@chmod(self::UCP_SECURITY_LOG, 0664);
}
/**
* Auto-déploie les VHosts Nginx UCP et le filtre Fail2ban si absents (ex. après git pull).
*/
public static function ensureUcpDeployed(): void
{
$st = self::getSettings();
if (empty($st['ucp_enabled'])) {
return;
}
$httpsPort = (int)($st['ucp_https_port'] ?? 8444);
$httpPort = (int)($st['ucp_http_port'] ?? 8084);
if ($httpsPort <= 0 && $httpPort <= 0) {
return;
}
$nginxFile = '/etc/nginx/sites-available/nexus-pbx';
$nginxConf = is_readable($nginxFile) ? (string)@file_get_contents($nginxFile) : '';
$hasNginxUcp = str_contains($nginxConf, self::UCP_ROOT_DIR);
$hasFilter = file_exists(self::UCP_FILTER_PATH);
$hasLog = file_exists(self::UCP_SECURITY_LOG);
if ($hasNginxUcp && $hasFilter && $hasLog) {
return;
}
if (function_exists('posix_geteuid') && posix_geteuid() !== 0) {
exec('sudo -n /var/www/nexus-pbx/scripts/nexus-firewall init-ucp 2>&1', $out, $code);
if ($code === 0) {
return;
}
}
self::saveSettings($st);
}
/**
* Ouvre/ferme les ports UCP configurés dans UFW / iptables uniquement si un filtrage actif existe.
*/
private static function syncUcpFirewallPorts(array $oldSettings, array $newSettings): void
{
$oldPorts = [];
if (!empty($oldSettings['ucp_enabled'])) {
if ((int)($oldSettings['ucp_https_port'] ?? 0) > 0) $oldPorts[] = (int)$oldSettings['ucp_https_port'];
if ((int)($oldSettings['ucp_http_port'] ?? 0) > 0) $oldPorts[] = (int)$oldSettings['ucp_http_port'];
}
$newPorts = [];
if (!empty($newSettings['ucp_enabled'])) {
if ((int)($newSettings['ucp_https_port'] ?? 0) > 0) $newPorts[] = (int)$newSettings['ucp_https_port'];
if ((int)($newSettings['ucp_http_port'] ?? 0) > 0) $newPorts[] = (int)$newSettings['ucp_http_port'];
}
$toRemove = array_diff(array_unique($oldPorts), array_unique($newPorts));
$toAdd = array_diff(array_unique($newPorts), array_unique($oldPorts));
// UFW si installé et actif
$ufwStatus = trim((string)@shell_exec('sudo -n /usr/sbin/ufw status 2>/dev/null'));
if ($ufwStatus !== '' && stripos($ufwStatus, 'Status: active') !== false) {
foreach ($toRemove as $p) {
@shell_exec('sudo -n /usr/sbin/ufw delete allow ' . (int)$p . '/tcp >/dev/null 2>&1');
}
foreach ($newPorts as $p) {
@shell_exec('sudo -n /usr/sbin/ufw allow ' . (int)$p . '/tcp >/dev/null 2>&1');
}
}
// iptables si la politique INPUT par défaut bloque le trafic (DROP/REJECT)
$iptPolicy = trim((string)@shell_exec("sudo -n /usr/sbin/iptables -S INPUT 2>/dev/null | head -n 1"));
if (str_contains($iptPolicy, 'DROP') || str_contains($iptPolicy, 'REJECT')) {
foreach ($toRemove as $p) {
@shell_exec("sudo -n /usr/sbin/iptables -D INPUT -p tcp --dport " . (int)$p . " -j ACCEPT >/dev/null 2>&1");
}
foreach ($toAdd as $p) {
@shell_exec("sudo -n /usr/sbin/iptables -C INPUT -p tcp --dport " . (int)$p . " -j ACCEPT >/dev/null 2>&1 || sudo -n /usr/sbin/iptables -I INPUT -p tcp --dport " . (int)$p . " -j ACCEPT >/dev/null 2>&1");
}
}
}
/**
* Résout l'IP réelle du client en tenant compte d'un reverse proxy local / RFC1918 de confiance.
*/
public static function resolveClientIp(): string
{
$remote = trim((string)($_SERVER['REMOTE_ADDR'] ?? '127.0.0.1'));
if ($remote === '' || !filter_var($remote, FILTER_VALIDATE_IP)) {
return '127.0.0.1';
}
// Ne faire confiance aux en-têtes proxy que si la connexion TCP provient d'un réseau privé ou loopback
$isTrustedProxy = (
filter_var($remote, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false
);
if ($isTrustedProxy) {
$candidates = [];
if (!empty($_SERVER['HTTP_X_REAL_IP'])) {
$candidates[] = trim((string)$_SERVER['HTTP_X_REAL_IP']);
}
if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
foreach (explode(',', (string)$_SERVER['HTTP_X_FORWARDED_FOR']) as $part) {
$candidates[] = trim($part);
}
}
foreach ($candidates as $cand) {
if ($cand !== '' && filter_var($cand, FILTER_VALIDATE_IP)) {
return $cand;
}
}
}
return $remote;
}
private static function getJailDetails(string $jail): array
{
exec('timeout 2 sudo -n /usr/bin/fail2ban-client status ' . escapeshellarg($jail) . ' 2>&1', $out);
@@ -526,7 +832,12 @@ EOF;
$totalFailed = max($totalFailed, $logIncidents);
} elseif ($jail === 'nexus-web') {
$logIncidents = is_readable('/var/log/nexus_security.log')
? (int)shell_exec("grep -c 'FailedLogin' /var/log/nexus_security.log 2>/dev/null")
? (int)shell_exec("grep -c -E 'NEXUS-WEB-AUTH-FAIL|FailedLogin' /var/log/nexus_security.log 2>/dev/null")
: 0;
$totalFailed = max($totalFailed, $logIncidents);
} elseif ($jail === 'nexus-ucp') {
$logIncidents = is_readable(self::UCP_SECURITY_LOG)
? (int)shell_exec("grep -c 'NEXUS-UCP-AUTH-FAIL' " . escapeshellarg(self::UCP_SECURITY_LOG) . " 2>/dev/null")
: 0;
$totalFailed = max($totalFailed, $logIncidents);
}
@@ -558,6 +869,7 @@ EOF;
return match ($jail) {
'asterisk' => 'Téléphonie SIP (PJSIP)',
'nexus-web' => 'Interface Web Admin',
'nexus-ucp' => 'Portail Utilisateur (UCP)',
'sshd' => 'Accès Console SSH',
default => ucfirst($jail),
};
+50 -25
View File
@@ -48,14 +48,22 @@ class PhonebookManager
CREATE INDEX IF NOT EXISTS idx_phonebook_group ON phonebook_contacts(group_name);
");
// 1. Récupère la cartographie des groupes d'extensions
$egRows = $db->query("SELECT id, name, members FROM extension_groups")->fetchAll();
$extToGroup = [];
// 1. Récupère la cartographie complète des groupes d'extensions (un poste peut appartenir à plusieurs groupes)
$egRows = $db->query("SELECT id, name, members FROM extension_groups ORDER BY name COLLATE NOCASE ASC")->fetchAll();
$extToGroups = [];
foreach ($egRows as $eg) {
$grpName = trim((string)($eg['name'] ?? ''));
if ($grpName === '') {
continue;
}
$members = json_decode((string)$eg['members'], true) ?: [];
foreach ($members as $extNum) {
if (!isset($extToGroup[(string)$extNum])) {
$extToGroup[(string)$extNum] = trim((string)$eg['name']);
$k = (string)$extNum;
if (!isset($extToGroups[$k])) {
$extToGroups[$k] = [];
}
if (!in_array($grpName, $extToGroups[$k], true)) {
$extToGroups[$k][] = $grpName;
}
}
}
@@ -80,7 +88,7 @@ class PhonebookManager
$extNum = trim((string)$ext['extension']);
$extName = trim((string)$ext['name']);
$email = trim((string)($ext['voicemail_email'] ?? ''));
$group = $extToGroup[$extNum] ?? 'Général';
$group = !empty($extToGroups[$extNum]) ? implode(', ', $extToGroups[$extNum]) : 'Général';
$activeExtIds[] = $extId;
$findStmt->execute([':ext_id' => $extId, ':num' => $extNum]);
@@ -122,33 +130,48 @@ class PhonebookManager
{
self::syncExtensions($db);
$params = [];
$sql = "SELECT * FROM phonebook_contacts";
$stmt = $db->query("SELECT * FROM phonebook_contacts ORDER BY display_name COLLATE NOCASE ASC");
$contacts = $stmt ? $stmt->fetchAll(PDO::FETCH_ASSOC) : [];
if ($group !== null && trim($group) !== '' && strtolower(trim($group)) !== 'all' && strtolower(trim($group)) !== 'tous') {
$cleanGroup = trim($group);
$sql .= " WHERE LOWER(group_name) = LOWER(:grp) OR LOWER(group_name) LIKE LOWER(:grp_like) OR LOWER(group_name) IN ('tous', 'all')";
$params[':grp'] = $cleanGroup;
$params[':grp_like'] = "%{$cleanGroup}%";
}
$cleanGroup = ($group !== null) ? trim($group) : '';
$filterByGroup = ($cleanGroup !== '' && !in_array(mb_strtolower($cleanGroup), ['all', 'tous'], true));
$targetLower = mb_strtolower($cleanGroup);
$sql .= " ORDER BY display_name COLLATE NOCASE ASC";
$stmt = $db->prepare($sql);
$stmt->execute($params);
$contacts = $stmt->fetchAll(PDO::FETCH_ASSOC);
// Vérification de la présence réelle du fichier sur le disque
foreach ($contacts as &$c) {
$result = [];
foreach ($contacts as $c) {
if (!empty($c['photo_url'])) {
$filename = basename((string)$c['photo_url']);
if (!file_exists(self::AVATARS_DIR . '/' . $filename)) {
$c['photo_url'] = '';
}
}
}
unset($c);
return $contacts;
$rawGroups = preg_split('/\s*,\s*/', trim((string)($c['group_name'] ?? ''))) ?: [];
$groupsList = array_values(array_unique(array_filter($rawGroups, fn($g) => $g !== '')));
if (empty($groupsList)) {
$groupsList = ['Général'];
}
$c['groups'] = $groupsList;
$c['group_name'] = implode(', ', $groupsList);
if ($filterByGroup) {
$matched = false;
foreach ($groupsList as $g) {
$gLower = mb_strtolower($g);
if ($gLower === $targetLower || in_array($gLower, ['tous', 'all'], true)) {
$matched = true;
break;
}
}
if (!$matched) {
continue;
}
}
$result[] = $c;
}
return $result;
}
/**
@@ -163,10 +186,12 @@ class PhonebookManager
if ($g !== '') $groups[$g] = true;
}
$pbRows = $db->query("SELECT DISTINCT group_name FROM phonebook_contacts WHERE group_name != ''")->fetchAll(PDO::FETCH_COLUMN);
foreach ($pbRows as $g) {
foreach ($pbRows as $raw) {
foreach (preg_split('/\s*,\s*/', trim((string)$raw)) ?: [] as $g) {
$g = trim((string)$g);
if ($g !== '') $groups[$g] = true;
}
}
// Ajoute Issy-Les-Moulineaux si pas encore présent
if (!isset($groups['Issy-Les-Moulineaux'])) {
+82 -5
View File
@@ -70,17 +70,22 @@ class SystemChecker
: 'Accès refusé en écriture dans /etc/asterisk pour PHP-FPM',
];
// 5. Directives #tryinclude dans pjsip.conf et extensions.conf
// 5. Directives #tryinclude dans pjsip.conf, extensions.conf, voicemail.conf et modules.conf
$pjsipMain = @file_get_contents('/etc/asterisk/pjsip.conf') ?: '';
$extMain = @file_get_contents('/etc/asterisk/extensions.conf') ?: '';
$incOk = str_contains($pjsipMain, 'pjsip.custom.conf') && str_contains($extMain, 'extensions.custom.conf');
$vmMain = @file_get_contents('/etc/asterisk/voicemail.conf') ?: '';
$modMain = @file_get_contents('/etc/asterisk/modules.conf') ?: '';
$incOk = str_contains($pjsipMain, 'pjsip.custom.conf')
&& str_contains($extMain, 'extensions.custom.conf')
&& str_contains($vmMain, 'voicemail.custom.conf')
&& str_contains($modMain, 'app_voicemail_odbc.so');
$checks[] = [
'id' => 'asterisk_includes',
'label' => 'Inclusions natives (#tryinclude *.custom.conf)',
'label' => 'Inclusions Asterisk & Voicemail (#tryinclude & modules.conf)',
'ok' => $incOk,
'detail' => $incOk
? 'pjsip.conf et extensions.conf incluent bien les fichiers .custom.conf'
: 'Directive #tryinclude manquante dans pjsip.conf ou extensions.conf',
? 'pjsip.conf, extensions.conf, voicemail.conf et modules.conf configurés'
: 'Directive #tryinclude manquante ou conflit modules.conf détecté',
];
// 6. Certificats TLS (Let's Encrypt ou Certificat local Debian)
@@ -142,6 +147,25 @@ class SystemChecker
public static function ensureAsteriskIncludes(): void
{
// 1. modules.conf: Désactiver les modules voicemail ODBC/IMAP incompatibles sur Debian (évite collision bloquante avec app_voicemail.so)
$modConf = '/etc/asterisk/modules.conf';
if (is_file($modConf) && is_writable($modConf)) {
$content = (string)file_get_contents($modConf);
$changed = false;
if (!str_contains($content, 'app_voicemail_odbc.so')) {
if (preg_match('/\[modules\]/i', $content)) {
$content = preg_replace('/\[modules\]/i', "[modules]\nnoload => app_voicemail_odbc.so\nnoload => app_voicemail_imap.so", $content, 1);
$changed = true;
} else {
$content = "[modules]\nautoload=yes\nnoload => app_voicemail_odbc.so\nnoload => app_voicemail_imap.so\n\n" . $content;
$changed = true;
}
}
if ($changed) {
file_put_contents($modConf, $content);
}
}
$pjsipConf = '/etc/asterisk/pjsip.conf';
if (is_file($pjsipConf) && is_writable($pjsipConf)) {
$content = (string)file_get_contents($pjsipConf);
@@ -177,8 +201,20 @@ class SystemChecker
if (is_file($vmConf) && is_writable($vmConf)) {
$content = (string)file_get_contents($vmConf);
$cleaned = preg_replace('/^1234 => 4242.*/m', ';1234 => 4242,Example Mailbox,root@localhost', $content);
if (preg_match('/^;\s*passwordlocation\s*=\s*spooldir/m', $cleaned)) {
$cleaned = preg_replace('/^;\s*passwordlocation\s*=\s*spooldir/m', 'passwordlocation=spooldir', $cleaned);
} elseif (!str_contains($cleaned, 'passwordlocation=spooldir') && !str_contains($cleaned, 'passwordlocation = spooldir')) {
if (preg_match('/\[general\]/i', $cleaned)) {
$cleaned = preg_replace('/\[general\]/i', "[general]\npasswordlocation=spooldir", $cleaned, 1);
}
}
if (preg_match('/^;\s*externpassnotify\s*=.*/m', $cleaned)) {
$cleaned = preg_replace('/^;\s*externpassnotify\s*=.*/m', 'externpassnotify=/usr/bin/php /var/www/nexus-pbx/scripts/voicemail_pass_sync.php', $cleaned);
} elseif (!str_contains($cleaned, 'externpassnotify=')) {
if (preg_match('/\[general\]/i', $cleaned)) {
$cleaned = preg_replace('/\[general\]/i', "[general]\nexternpassnotify=/usr/bin/php /var/www/nexus-pbx/scripts/voicemail_pass_sync.php", $cleaned, 1);
}
}
if (!str_contains($cleaned, 'voicemail.custom.conf')) {
$cleaned = rtrim($cleaned) . "\n\n#tryinclude \"voicemail.custom.conf\"\n";
}
@@ -187,6 +223,47 @@ class SystemChecker
}
}
@mkdir('/var/spool/asterisk/voicemail/default', 0775, true);
// Liens vers les répertoires de sons Debian (/usr/share/asterisk/sounds -> /var/lib/asterisk/sounds)
@mkdir('/var/lib/asterisk/sounds', 0775, true);
@mkdir('/var/lib/asterisk/sounds/custom', 0775, true);
@mkdir('/var/lib/asterisk/moh', 0775, true);
foreach (['fr', 'en', 'es'] as $lang) {
$src = "/usr/share/asterisk/sounds/{$lang}";
$dst = "/var/lib/asterisk/sounds/{$lang}";
if (is_dir($src) && !file_exists($dst)) {
@symlink($src, $dst);
}
if (is_dir($dst) && !file_exists("{$dst}/custom")) {
@symlink('/var/lib/asterisk/sounds/custom', "{$dst}/custom");
}
}
if (!file_exists('/usr/share/asterisk/sounds/custom')) {
@symlink('/var/lib/asterisk/sounds/custom', '/usr/share/asterisk/sounds/custom');
}
// Synchroniser manager.conf avec le mot de passe AMI de la base SQLite si nécessaire (ex: après restauration de backup)
$mgrConf = '/etc/asterisk/manager.conf';
if (is_file($mgrConf) && is_writable($mgrConf)) {
try {
$db = Database::getConnection();
$st = $db->query('SELECT ami_user, ami_secret FROM settings WHERE id = 1')->fetch();
if (!empty($st['ami_secret'])) {
$sec = trim((string)$st['ami_secret']);
$usr = trim((string)($st['ami_user'] ?? 'pbxgui')) ?: 'pbxgui';
$content = (string)file_get_contents($mgrConf);
if (str_contains($content, "[{$usr}]")) {
$updated = preg_replace("/(\[{$usr}\][^\[]*secret\s*=\s*)([^\r\n]+)/i", "\${1}{$sec}", $content);
if ($updated !== null && $updated !== $content) {
file_put_contents($mgrConf, $updated);
@exec('asterisk -rx "manager reload" 2>/dev/null || true');
}
}
}
} catch (\Throwable) {}
}
$astConf = '/etc/asterisk/asterisk.conf';
if (is_file($astConf) && is_writable($astConf)) {
$content = (string)file_get_contents($astConf);
+36 -4
View File
@@ -298,17 +298,19 @@ class SystemMetrics
}
}
$seenExts = [];
foreach (preg_split('/\r?\n/', $rawContacts) ?: [] as $line) {
// Contact: 101/sip:101@10.10.167.162:5060 5dd9a21be8 Avail 9.234
// or with -nan: Contact: 102/sip:102@10.10.168.228:5060;x-ast-txp=trans bb0686ce80 Unavail -nan
if (preg_match('/Contact:\s+([^\s\/]+)\/sip:([^\s]+)\s+([a-f0-9]+)\s+(\S+)\s+(-?[0-9.]+|nan|-nan)/i', $line, $m)) {
// Supporte sip:, sips:, sans préfixe, tous types de hash et RTT (numérique, nan, N/A ou absent)
if (preg_match('/Contact:\s+([^\s\/]+)\/(?:sips?:)?([^\s]+)\s+([a-zA-Z0-9]+)\s+(\S+)(?:\s+(\S+))?/i', $line, $m)) {
$extNum = $m[1];
$uri = $m[2];
$status = $m[4];
$rttRaw = $m[5] ?? null;
$isAvail = (stripos($status, 'Avail') !== false && stripos($status, 'Unavail') === false) || stripos($status, 'NonQual') !== false;
if ($isAvail) {
$onlineCount++;
}
$seenExts[$extNum] = true;
$meta = $contactMeta[$extNum] ?? [];
$fullUri = $meta['uri'] ?? ("sip:" . $uri);
@@ -330,7 +332,37 @@ class SystemMetrics
'user_agent' => $userAgent,
'active_transport' => $activeTransport,
'status' => $status,
'rtt_ms' => is_numeric($m[5]) ? round((float)$m[5], 1) : null,
'rtt_ms' => ($rttRaw !== null && is_numeric($rttRaw)) ? round((float)$rttRaw, 1) : null,
];
}
}
// Filet de sécurité 100% infaillible : Tous les contacts enregistrés dans AstDB (/registrar/contact)
// non encore répertoriés (par exemple si qualify est désactivé ou RTT non disponible)
$nowTime = time();
foreach ($contactMeta as $extNum => $meta) {
if (isset($seenExts[$extNum])) {
continue;
}
$exp = (int)($meta['expiration_time'] ?? 0);
if ($exp === 0 || $exp > $nowTime) {
$onlineCount++;
$fullUri = $meta['uri'] ?? '';
$activeTransport = 'udp';
if (preg_match('/transport=(tls|wss|ws|tcp|udp)/i', $fullUri, $tm)) {
$activeTransport = strtolower($tm[1]);
} elseif (preg_match('/x-ast-txp=transport-(tls|wss|ws|tcp|udp)/i', $fullUri, $tm)) {
$activeTransport = strtolower($tm[1]);
} elseif (str_starts_with($fullUri, 'sips:')) {
$activeTransport = 'tls';
}
$contacts[] = [
'extension' => (string)$extNum,
'uri' => $fullUri,
'user_agent' => $meta['user_agent'] ?? '',
'active_transport' => $activeTransport,
'status' => 'Avail',
'rtt_ms' => null,
];
}
}
+197
View File
@@ -0,0 +1,197 @@
<?php
declare(strict_types=1);
namespace App;
use PDO;
use InvalidArgumentException;
class UcpAuth
{
public const SESSION_NAME = 'NEXUS_UCP_SESS';
public static function startSession(): void
{
if (session_status() === PHP_SESSION_NONE) {
session_name(self::SESSION_NAME);
session_start([
'cookie_httponly' => true,
'cookie_samesite' => 'Lax',
'cookie_path' => '/',
]);
}
if (empty($_SESSION['ucp_csrf'])) {
$_SESSION['ucp_csrf'] = bin2hex(random_bytes(24));
}
}
public static function getCsrfToken(): string
{
self::startSession();
return (string)$_SESSION['ucp_csrf'];
}
public static function verifyCsrfToken(string $token): bool
{
self::startSession();
$expected = (string)($_SESSION['ucp_csrf'] ?? '');
return $expected !== '' && hash_equals($expected, $token);
}
public static function isUcpEnabled(PDO $db): bool
{
try {
$val = $db->query('SELECT ucp_enabled FROM settings WHERE id = 1')->fetchColumn();
return $val === false ? true : ((int)$val === 1);
} catch (\Throwable) {
return true;
}
}
public static function getCurrentUser(PDO $db): ?array
{
self::startSession();
if (empty($_SESSION['ucp_user_id']) || !self::isUcpEnabled($db)) {
return null;
}
$stmt = $db->prepare('
SELECT id, username, display_name, email, extensions_json, enabled, last_login_at, last_login_ip
FROM ucp_users
WHERE id = :id
LIMIT 1
');
$stmt->execute([':id' => (int)$_SESSION['ucp_user_id']]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$user || (int)$user['enabled'] !== 1) {
self::logout();
return null;
}
return $user;
}
public static function isAuthenticated(PDO $db): bool
{
return self::getCurrentUser($db) !== null;
}
public static function login(PDO $db, string $username, string $password): bool
{
self::startSession();
if (!self::isUcpEnabled($db)) {
return false;
}
$cleanUser = trim($username);
$stmt = $db->prepare('
SELECT id, username, display_name, password_hash, enabled
FROM ucp_users
WHERE username = :u
LIMIT 1
');
$stmt->execute([':u' => $cleanUser]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
if ($user && (int)$user['enabled'] === 1 && password_verify($password, (string)$user['password_hash'])) {
session_regenerate_id(true);
$_SESSION['ucp_user_id'] = (int)$user['id'];
$_SESSION['ucp_username'] = (string)$user['username'];
if (empty($_SESSION['ucp_csrf'])) {
$_SESSION['ucp_csrf'] = bin2hex(random_bytes(24));
}
$ip = FirewallManager::resolveClientIp();
$db->prepare('UPDATE ucp_users SET last_login_at = CURRENT_TIMESTAMP, last_login_ip = :ip WHERE id = :id')
->execute([':ip' => $ip, ':id' => (int)$user['id']]);
return true;
}
self::logAuthFailure($cleanUser);
return false;
}
public static function logAuthFailure(string $username): void
{
$clientIp = FirewallManager::resolveClientIp();
$safeUser = preg_replace('/[^a-zA-Z0-9._@\-]/', '', $username) ?: 'vide';
$logMsg = date('[Y-m-d H:i:s] ') . "[NEXUS-UCP-AUTH-FAIL]: {$clientIp} (username: {$safeUser})\n";
@file_put_contents(FirewallManager::UCP_SECURITY_LOG, $logMsg, FILE_APPEND | LOCK_EX);
@chmod(FirewallManager::UCP_SECURITY_LOG, 0664);
}
public static function logout(): void
{
self::startSession();
$_SESSION = [];
if (ini_get('session.use_cookies')) {
$params = session_get_cookie_params();
setcookie(
session_name(),
'',
time() - 42000,
$params['path'],
$params['domain'],
$params['secure'],
$params['httponly']
);
}
session_destroy();
}
/**
* Retourne la liste fraîche des extensions autorisées et actives pour un utilisateur UCP.
*/
public static function getAllowedExtensions(PDO $db, int $ucpUserId): array
{
$stmt = $db->prepare('SELECT extensions_json, enabled FROM ucp_users WHERE id = :id LIMIT 1');
$stmt->execute([':id' => $ucpUserId]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$row || (int)$row['enabled'] !== 1) {
return [];
}
$linked = json_decode((string)($row['extensions_json'] ?? '[]'), true);
if (!is_array($linked) || empty($linked)) {
return [];
}
$linkedSet = array_values(array_unique(array_map('strval', $linked)));
$extRows = $db->query('
SELECT id, extension, name, transport, encryption, groundwire_enabled, voicemail_enabled, voicemail_email, voicemail_pin, ring_time
FROM extensions
WHERE enabled = 1
ORDER BY extension ASC
')->fetchAll(PDO::FETCH_ASSOC) ?: [];
$allowed = [];
foreach ($extRows as $ext) {
if (in_array((string)$ext['extension'], $linkedSet, true)) {
$allowed[] = $ext;
}
}
return $allowed;
}
public static function updateOwnPassword(PDO $db, int $ucpUserId, string $currentPassword, string $newPassword): void
{
if (strlen($newPassword) < 6) {
throw new InvalidArgumentException("Le nouveau mot de passe doit contenir au moins 6 caractères.");
}
$stmt = $db->prepare('SELECT password_hash FROM ucp_users WHERE id = :id LIMIT 1');
$stmt->execute([':id' => $ucpUserId]);
$hash = $stmt->fetchColumn();
if (!$hash || !password_verify($currentPassword, (string)$hash)) {
throw new InvalidArgumentException("Le mot de passe actuel est incorrect.");
}
$db->prepare('UPDATE ucp_users SET password_hash = :p, updated_at = CURRENT_TIMESTAMP WHERE id = :id')
->execute([
':p' => password_hash($newPassword, PASSWORD_DEFAULT),
':id' => $ucpUserId,
]);
}
}
+1024
View File
File diff suppressed because it is too large. Load diff
+348
View File
@@ -0,0 +1,348 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../src/Database.php';
require_once __DIR__ . '/../src/FirewallManager.php';
require_once __DIR__ . '/../src/AmiClient.php';
require_once __DIR__ . '/../src/ConfigGenerator.php';
require_once __DIR__ . '/../src/CdrManager.php';
require_once __DIR__ . '/../src/PhonebookManager.php';
require_once __DIR__ . '/../src/UcpAuth.php';
require_once __DIR__ . '/../src/UcpManager.php';
use App\Database;
use App\UcpAuth;
use App\UcpManager;
$action = trim((string)($_GET['action'] ?? $_POST['action'] ?? ''));
$isBinaryEndpoint = in_array($action, ['stream_voicemail', 'avatar'], true);
if (!$isBinaryEndpoint) {
header('Content-Type: application/json; charset=utf-8');
}
try {
$db = Database::getConnection();
UcpAuth::startSession();
if (!UcpAuth::isUcpEnabled($db)) {
http_response_code(503);
echo json_encode(['success' => false, 'message' => 'Le portail utilisateur UCP est actuellement désactivé par l’administrateur.']);
exit;
}
// Lecture du payload JSON si applicable
$payload = $_POST;
$contentType = (string)($_SERVER['CONTENT_TYPE'] ?? '');
if (str_contains($contentType, 'application/json')) {
$raw = file_get_contents('php://input');
$decoded = json_decode((string)$raw, true);
if (is_array($decoded)) {
$payload = $decoded;
}
}
// -------------------------------------------------------------------------
// 1. Authentification (routes publiques de session)
// -------------------------------------------------------------------------
if ($action === 'login') {
$username = (string)($payload['username'] ?? '');
$password = (string)($payload['password'] ?? '');
if (UcpAuth::login($db, $username, $password)) {
$user = UcpAuth::getCurrentUser($db);
echo json_encode([
'success' => true,
'username' => $user['username'] ?? $username,
'csrf_token' => UcpAuth::getCsrfToken(),
'message' => 'Connexion réussie.',
]);
} else {
http_response_code(401);
echo json_encode([
'success' => false,
'message' => 'Identifiant ou mot de passe incorrect.',
]);
}
exit;
}
if ($action === 'logout') {
UcpAuth::logout();
echo json_encode(['success' => true, 'message' => 'Déconnexion effectuée.']);
exit;
}
// -------------------------------------------------------------------------
// 2. Barrière d'authentification UCP obligatoire
// -------------------------------------------------------------------------
$currentUser = UcpAuth::getCurrentUser($db);
if (!$currentUser) {
http_response_code(401);
echo json_encode(['success' => false, 'message' => 'Session expirée ou non authentifiée.']);
exit;
}
// Protection CSRF sur toutes les actions modificatrices POST
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$csrfHeader = (string)($_SERVER['HTTP_X_CSRF_TOKEN'] ?? $payload['csrf_token'] ?? '');
if (!UcpAuth::verifyCsrfToken($csrfHeader)) {
http_response_code(403);
echo json_encode(['success' => false, 'message' => 'Jeton de sécurité CSRF invalide. Rechargez la page.']);
exit;
}
}
$ucpUserId = (int)$currentUser['id'];
$allowedExts = UcpAuth::getAllowedExtensions($db, $ucpUserId);
switch ($action) {
case 'get_state':
$branding = $db->query('SELECT company_name, company_logo, ucp_logo FROM settings WHERE id = 1 LIMIT 1')->fetch(PDO::FETCH_ASSOC) ?: [];
$compLogo = trim((string)($branding['company_logo'] ?? ''));
$ucpLogo = trim((string)($branding['ucp_logo'] ?? ''));
echo json_encode([
'success' => true,
'data' => [
'user' => [
'id' => $ucpUserId,
'username' => (string)$currentUser['username'],
'display_name' => (string)$currentUser['display_name'],
'email' => (string)$currentUser['email'],
'last_login_at' => $currentUser['last_login_at'],
],
'branding' => [
'company_name' => (string)($branding['company_name'] ?? ''),
'company_logo' => $compLogo,
'ucp_logo' => $ucpLogo,
'effective_logo' => $ucpLogo !== '' ? $ucpLogo : $compLogo,
],
'csrf_token' => UcpAuth::getCsrfToken(),
'extensions' => UcpManager::getExtensionsState($db, $allowedExts, $ucpUserId),
'forward_targets' => UcpManager::getForwardTargets($db),
'feature_codes' => UcpManager::getFeatureCodesForUser($db),
],
]);
break;
case 'save_call_features':
$ext = (string)($payload['extension'] ?? '');
UcpManager::setCallFeatures($db, $allowedExts, $ext, [
'dnd' => !empty($payload['dnd']),
'cf' => (string)($payload['cf'] ?? ''),
'cfu' => (string)($payload['cfu'] ?? ''),
]);
echo json_encode([
'success' => true,
'extensions' => UcpManager::getExtensionsState($db, $allowedExts, $ucpUserId),
'message' => "Paramètres d'appel du poste {$ext} appliqués en direct.",
]);
break;
case 'get_voicemails':
$ext = (string)($_GET['extension'] ?? $payload['extension'] ?? '');
if ($ext === '' && !empty($allowedExts)) {
$ext = (string)$allowedExts[0]['extension'];
}
$messages = $ext !== '' ? UcpManager::getVoicemails($allowedExts, $ext) : [];
echo json_encode([
'success' => true,
'messages' => $messages,
]);
break;
case 'stream_voicemail':
$ext = (string)($_GET['extension'] ?? '');
$folder = (string)($_GET['folder'] ?? '');
$msgId = (string)($_GET['id'] ?? '');
$download = !empty($_GET['download']);
$filePath = UcpManager::getVoicemailAudioPath($allowedExts, $ext, $folder, $msgId);
$size = filesize($filePath);
$fileExt = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));
$mime = ($fileExt === 'gsm') ? 'audio/gsm' : 'audio/wav';
while (ob_get_level()) {
ob_end_clean();
}
header("Content-Type: {$mime}");
header('Accept-Ranges: bytes');
header('Cache-Control: no-store, no-cache, must-revalidate');
if ($download) {
$dlName = "voicemail_{$ext}_{$msgId}.{$fileExt}";
header("Content-Disposition: attachment; filename=\"{$dlName}\"");
}
if (isset($_SERVER['HTTP_RANGE'])) {
[$param, $range] = explode('=', $_SERVER['HTTP_RANGE'], 2);
if (strtolower(trim($param)) === 'bytes') {
[$from, $to] = explode('-', $range, 2);
$from = intval($from);
$to = ($to === '') ? ($size - 1) : intval($to);
if ($from > $to || $from >= $size) {
http_response_code(416);
exit;
}
$length = $to - $from + 1;
http_response_code(206);
header("Content-Range: bytes {$from}-{$to}/{$size}");
header("Content-Length: {$length}");
$fp = fopen($filePath, 'rb');
fseek($fp, $from);
$remaining = $length;
while ($remaining > 0 && !feof($fp)) {
$chunk = min(8192, $remaining);
echo fread($fp, $chunk);
$remaining -= $chunk;
flush();
}
fclose($fp);
exit;
}
}
header("Content-Length: {$size}");
readfile($filePath);
exit;
case 'toggle_voicemail_read':
$ext = (string)($payload['extension'] ?? '');
$folder = (string)($payload['folder'] ?? '');
$msgId = (string)($payload['id'] ?? '');
UcpManager::moveVoicemailFolder($db, $allowedExts, $ext, $folder, $msgId);
echo json_encode([
'success' => true,
'messages' => UcpManager::getVoicemails($allowedExts, $ext),
'extensions' => UcpManager::getExtensionsState($db, $allowedExts),
'message' => ($folder === 'INBOX') ? 'Message marqué comme lu (archivé).' : 'Message marqué comme non lu.',
]);
break;
case 'delete_voicemail':
$ext = (string)($payload['extension'] ?? '');
$folder = (string)($payload['folder'] ?? '');
$msgId = (string)($payload['id'] ?? '');
UcpManager::deleteVoicemail($db, $allowedExts, $ext, $folder, $msgId);
echo json_encode([
'success' => true,
'messages' => UcpManager::getVoicemails($allowedExts, $ext),
'extensions' => UcpManager::getExtensionsState($db, $allowedExts),
'message' => 'Message vocal supprimé définitivement.',
]);
break;
case 'save_voicemail_prefs':
$ext = (string)($payload['extension'] ?? '');
$pin = (string)($payload['pin'] ?? '');
$email = (string)($payload['email'] ?? '');
UcpManager::updateVoicemailPrefs($db, $allowedExts, $ext, $pin, $email);
$freshAllowed = UcpAuth::getAllowedExtensions($db, $ucpUserId);
echo json_encode([
'success' => true,
'extensions' => UcpManager::getExtensionsState($db, $freshAllowed),
'message' => 'Préférences messagerie vocale enregistrées et appliquées.',
]);
break;
case 'get_personal_cdr':
$data = UcpManager::getPersonalCdr($db, $allowedExts, [
'direction' => $_GET['direction'] ?? 'all',
'disposition' => $_GET['disposition'] ?? 'all',
'period' => $_GET['period'] ?? 'all',
'search' => $_GET['search'] ?? '',
]);
echo json_encode([
'success' => true,
'data' => $data,
]);
break;
case 'get_phonebook':
echo json_encode([
'success' => true,
'contacts' => UcpManager::getPhonebookForUser($db, $allowedExts),
]);
break;
case 'save_my_email':
$ext = (string)($payload['extension'] ?? '');
$email = (string)($payload['email'] ?? '');
UcpManager::updateMyContactEmail($db, $allowedExts, $ext, $email);
echo json_encode([
'success' => true,
'contacts' => UcpManager::getPhonebookForUser($db, $allowedExts),
'extensions' => UcpManager::getExtensionsState($db, $allowedExts),
'message' => "E-mail annuaire du poste {$ext} mis à jour.",
]);
break;
case 'upload_my_avatar':
$ext = (string)($_POST['extension'] ?? '');
if (empty($_FILES['avatar'])) {
throw new InvalidArgumentException("Aucun fichier image reçu.");
}
$avatarUrl = UcpManager::uploadMyAvatar($db, $allowedExts, $ext, $_FILES['avatar']);
echo json_encode([
'success' => true,
'photo_url' => $avatarUrl,
'contacts' => UcpManager::getPhonebookForUser($db, $allowedExts),
'extensions' => UcpManager::getExtensionsState($db, $allowedExts),
'message' => "Photo de profil du poste {$ext} mise à jour.",
]);
break;
case 'avatar':
$file = (string)($_GET['file'] ?? '');
$path = UcpManager::resolveAvatarPath($file);
if (!$path) {
http_response_code(404);
exit;
}
$extName = strtolower(pathinfo($path, PATHINFO_EXTENSION));
$mime = match ($extName) {
'png' => 'image/png',
'webp' => 'image/webp',
default => 'image/jpeg',
};
while (ob_get_level()) {
ob_end_clean();
}
header("Content-Type: {$mime}");
header('Cache-Control: public, max-age=3600');
readfile($path);
exit;
case 'change_password':
$cur = (string)($payload['current_password'] ?? '');
$new = (string)($payload['new_password'] ?? '');
UcpAuth::updateOwnPassword($db, $ucpUserId, $cur, $new);
echo json_encode([
'success' => true,
'message' => 'Mot de passe UCP modifié avec succès.',
]);
break;
default:
http_response_code(400);
echo json_encode(['success' => false, 'message' => 'Action UCP inconnue.']);
break;
}
} catch (InvalidArgumentException $e) {
http_response_code(400);
if ($isBinaryEndpoint) {
header('Content-Type: text/plain; charset=utf-8');
echo $e->getMessage();
} else {
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
}
} catch (\Throwable $e) {
http_response_code(500);
if ($isBinaryEndpoint) {
header('Content-Type: text/plain; charset=utf-8');
echo 'Erreur serveur.';
} else {
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 795 KiB

+1207
View File
File diff suppressed because it is too large. Load diff
+89
View File
@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../src/Database.php';
require_once __DIR__ . '/../src/FirewallManager.php';
require_once __DIR__ . '/../src/AmiClient.php';
require_once __DIR__ . '/../src/ConfigGenerator.php';
require_once __DIR__ . '/../src/CdrManager.php';
require_once __DIR__ . '/../src/PhonebookManager.php';
require_once __DIR__ . '/../src/UcpAuth.php';
require_once __DIR__ . '/../src/UcpManager.php';
use App\Database;
use App\UcpAuth;
use App\UcpManager;
try {
$db = Database::getConnection();
if (!UcpAuth::isUcpEnabled($db)) {
http_response_code(503);
header('Content-Type: text/plain; charset=utf-8');
echo "Le portail utilisateur UCP est actuellement désactivé.";
exit;
}
$extId = (int)($_GET['ext_id'] ?? $_GET['line_id'] ?? 0);
$uid = (int)($_GET['uid'] ?? 0);
$token = trim((string)($_GET['token'] ?? ''));
$authorized = false;
// 1. Authentification sans session par signature HMAC GET (utilisée par l'application GroundWire / Acrobits)
if ($token !== '' && $extId > 0 && $uid > 0) {
if (UcpManager::verifyGroundwireToken($db, $extId, $uid, $token)) {
$allowed = UcpAuth::getAllowedExtensions($db, $uid);
foreach ($allowed as $row) {
if ((int)$row['id'] === $extId && (int)($row['groundwire_enabled'] ?? 0) === 1) {
$authorized = true;
break;
}
}
}
}
// 2. Sinon : utilisateur UCP connecté en session et propriétaire de l'extension
if (!$authorized && $extId > 0) {
UcpAuth::startSession();
$sessionUser = UcpAuth::getCurrentUser($db);
if ($sessionUser !== null) {
$allowed = UcpAuth::getAllowedExtensions($db, (int)$sessionUser['id']);
foreach ($allowed as $row) {
if ((int)$row['id'] === $extId && (int)($row['groundwire_enabled'] ?? 0) === 1) {
$authorized = true;
break;
}
}
}
}
if (!$authorized) {
http_response_code(403);
header('Content-Type: text/plain; charset=utf-8');
echo "Accès refusé ou lien de provisionnement invalide.";
exit;
}
$bundle = UcpManager::buildGroundwireXml($db, $extId);
$xmlContent = $bundle['xml'];
while (ob_get_level()) {
ob_end_clean();
}
header('Content-Type: application/xml; charset=utf-8');
header('Content-Disposition: attachment; filename="' . $bundle['filename'] . '"');
header('Content-Length: ' . strlen($xmlContent));
header('Cache-Control: no-store, no-cache, must-revalidate');
echo $xmlContent;
} catch (\InvalidArgumentException $e) {
http_response_code(404);
header('Content-Type: text/plain; charset=utf-8');
echo $e->getMessage();
} catch (\Throwable $e) {
http_response_code(500);
header('Content-Type: text/plain; charset=utf-8');
echo "Erreur interne lors de la génération du profil XML.";
}
+578
View File
@@ -0,0 +1,578 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../src/Database.php';
require_once __DIR__ . '/../src/FirewallManager.php';
require_once __DIR__ . '/../src/UcpAuth.php';
use App\Database;
use App\UcpAuth;
$db = Database::getConnection();
$ucpEnabled = UcpAuth::isUcpEnabled($db);
$currentUser = $ucpEnabled ? UcpAuth::getCurrentUser($db) : null;
$isAuth = ($currentUser !== null);
$branding = $db->query('SELECT company_name, company_logo, ucp_logo FROM settings WHERE id = 1 LIMIT 1')->fetch(\PDO::FETCH_ASSOC) ?: [];
$companyName = trim((string)($branding['company_name'] ?? ''));
$companyLogo = trim((string)($branding['company_logo'] ?? ''));
$ucpLogo = trim((string)($branding['ucp_logo'] ?? ''));
$ucpEffectiveLogo = $ucpLogo !== '' ? $ucpLogo : $companyLogo;
$appVerData = file_exists(__DIR__ . '/../version.json') ? json_decode((string)file_get_contents(__DIR__ . '/../version.json'), true) : [];
$appVersion = $appVerData['version'] ?? '2.5.0';
$csrfToken = UcpAuth::getCsrfToken();
?>
<!DOCTYPE html>
<html lang="fr" class="h-full bg-slate-50">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title><?= htmlspecialchars($companyName ?: 'NexusPBX') ?> — Portail Utilisateur (UCP)</title>
<link rel="icon" type="image/png" href="/assets/favicon.png?v=<?= file_exists(__DIR__ . '/assets/favicon.png') ? filemtime(__DIR__ . '/assets/favicon.png') : 1 ?>">
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: {
sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'],
mono: ['JetBrains Mono', 'Fira Code', 'monospace'],
}
}
}
};
</script>
<style>
html, body {
max-width: 100%;
overflow-x: hidden;
-webkit-text-size-adjust: 100%;
}
body { background-color: #f8fafc; color: #0f172a; }
.ucp-card { background: #ffffff; border: 1px solid #e2e8f0; box-shadow: 0 1px 3px rgba(15, 23, 42, 0.04); }
@media screen and (max-width: 639px) {
input, select, textarea {
font-size: 16px !important;
}
}
.ucp-nav-scroll {
scrollbar-width: none;
-ms-overflow-style: none;
}
.ucp-nav-scroll::-webkit-scrollbar {
display: none;
}
::-webkit-scrollbar { width: 7px; height: 7px; }
::-webkit-scrollbar-track { background: #f1f5f9; }
::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
@page {
size: A4 portrait;
margin: 11mm 12mm;
}
@media print {
html, body {
background: #ffffff !important;
color: #0f172a !important;
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
}
header, #ucp-toast-container, #ucp-profile-modal, .no-print {
display: none !important;
}
main {
max-width: 100% !important;
padding: 0 !important;
margin: 0 !important;
}
.ucp-panel {
display: none !important;
}
#ucp-panel-guide {
display: block !important;
margin: 0 !important;
padding: 0 !important;
}
#ucp-guide-printable {
border: none !important;
box-shadow: none !important;
padding: 0 !important;
margin: 0 !important;
}
.ucp-print-avoid-break {
break-inside: avoid !important;
page-break-inside: avoid !important;
}
.ucp-print-3col {
display: grid !important;
grid-template-columns: repeat(3, minmax(0, 1fr)) !important;
gap: 0.75rem !important;
}
.ucp-print-row {
flex-direction: row !important;
align-items: center !important;
}
}
</style>
</head>
<body class="min-h-full font-sans antialiased text-slate-800">
<!-- Toast Container -->
<div id="ucp-toast-container" class="fixed top-4 inset-x-3 sm:inset-x-auto sm:right-4 z-50 flex flex-col gap-2 sm:max-w-sm sm:w-full pointer-events-none"></div>
<?php if (!$ucpEnabled): ?>
<div class="min-h-screen flex items-center justify-center p-6">
<div class="max-w-md w-full ucp-card rounded-2xl p-8 text-center space-y-3">
<div class="w-12 h-12 mx-auto rounded-xl bg-amber-50 border border-amber-200 flex items-center justify-center text-amber-600">
<svg class="w-6 h-6" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z"/></svg>
</div>
<h1 class="text-lg font-bold text-slate-900">Portail Utilisateur désactivé</h1>
<p class="text-sm text-slate-500">Ce service a été temporairement désactivé par votre administrateur système.</p>
</div>
</div>
<?php elseif (!$isAuth): ?>
<!-- ÉCRAN DE CONNEXION UCP (WALLPAPER CLAIR + CARTE GLASSMORPHISM) -->
<div class="min-h-screen flex flex-col items-center justify-center p-4 relative overflow-hidden bg-slate-100">
<div class="absolute inset-0 bg-cover bg-center bg-no-repeat" style="background-image: url('/assets/login-bg.jpg?v=<?= file_exists(__DIR__ . '/assets/login-bg.jpg') ? filemtime(__DIR__ . '/assets/login-bg.jpg') : 1 ?>');"></div>
<div class="absolute inset-0 bg-white/10"></div>
<div class="relative z-10 w-full max-w-md bg-white/85 backdrop-blur-xl rounded-3xl p-8 shadow-xl shadow-indigo-900/10 border border-white/80">
<div class="flex flex-col items-center text-center mb-7">
<?php if ($ucpEffectiveLogo !== ''): ?>
<img src="<?= htmlspecialchars($ucpEffectiveLogo) ?>" alt="Logo" class="h-14 w-auto object-contain mb-3 rounded-xl">
<?php else: ?>
<img src="/assets/logo.png?v=1" alt="NexusPBX" class="h-14 w-auto object-contain mb-3">
<?php endif; ?>
<span class="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-indigo-50 border border-indigo-100 text-indigo-700 text-[11px] font-semibold uppercase tracking-wider mb-2">
Portail Utilisateur • UCP
</span>
<h1 class="text-xl font-bold text-slate-900"><?= htmlspecialchars($companyName ?: 'NexusPBX') ?></h1>
<p class="text-xs text-slate-500 mt-1">Connectez-vous pour gérer vos postes, renvois, messagerie et annuaire</p>
</div>
<form id="ucp-login-form" class="space-y-4">
<div>
<label for="ucp-login-user" class="block text-xs font-semibold text-slate-700 mb-1.5">Identifiant utilisateur</label>
<input type="text" id="ucp-login-user" name="username" required autocomplete="username" autofocus
placeholder="Votre identifiant"
class="w-full px-3.5 py-2.5 rounded-xl bg-slate-50 border border-slate-300 text-base sm:text-sm text-slate-900 focus:bg-white focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500 focus:outline-none transition">
</div>
<div>
<label for="ucp-login-pass" class="block text-xs font-semibold text-slate-700 mb-1.5">Mot de passe</label>
<input type="password" id="ucp-login-pass" name="password" required autocomplete="current-password"
placeholder="••••••••"
class="w-full px-3.5 py-2.5 rounded-xl bg-slate-50 border border-slate-300 text-base sm:text-sm text-slate-900 focus:bg-white focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500 focus:outline-none transition">
</div>
<div id="ucp-login-error" class="hidden rounded-xl bg-rose-50 border border-rose-200 px-3.5 py-2.5 text-xs font-medium text-rose-700"></div>
<button type="submit" id="ucp-login-btn"
class="w-full py-2.5 px-4 rounded-xl bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800 text-white text-sm font-semibold shadow-sm shadow-indigo-500/20 transition">
Se connecter au portail
</button>
</form>
<div class="mt-6 pt-5 border-t border-slate-100 text-center text-[11px] text-slate-400">
Espace utilisateur isolé • Protégé par Fail2ban • NexusPBX v<?= htmlspecialchars($appVersion) ?>
</div>
</div>
</div>
<?php else: ?>
<!-- INTERFACE PRINCIPALE UCP -->
<div class="min-h-screen flex flex-col overflow-x-hidden">
<!-- Top Navbar -->
<header class="sticky top-0 z-30 bg-white/95 backdrop-blur border-b border-slate-200">
<div class="max-w-6xl mx-auto px-4 sm:px-6 h-16 flex items-center justify-between gap-4">
<div class="flex items-center gap-3 min-w-0">
<?php if ($ucpEffectiveLogo !== ''): ?>
<img src="<?= htmlspecialchars($ucpEffectiveLogo) ?>" alt="Logo" class="h-9 w-auto rounded-lg object-contain shrink-0">
<?php else: ?>
<img src="/assets/logo.png?v=1" alt="NexusPBX" class="h-9 w-auto object-contain shrink-0">
<?php endif; ?>
<div class="min-w-0">
<div class="flex items-center gap-2">
<span class="font-bold text-slate-900 text-sm sm:text-base truncate"><?= htmlspecialchars($companyName ?: 'NexusPBX') ?></span>
<span class="hidden sm:inline-flex px-2 py-0.5 rounded-full bg-indigo-50 text-indigo-700 border border-indigo-100 text-[10px] font-bold uppercase tracking-wide">UCP</span>
</div>
<p class="text-[11px] text-slate-500 truncate"><span class="hidden sm:inline">Espace personnel de </span><strong class="text-slate-700"><?= htmlspecialchars((string)($currentUser['display_name'] ?: $currentUser['username'])) ?></strong></p>
</div>
</div>
<div class="flex items-center gap-2 shrink-0">
<button type="button" onclick="refreshUcpState(true)" title="Actualiser" aria-label="Actualiser"
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl border border-slate-200 bg-white hover:bg-slate-50 text-xs font-semibold text-slate-600 transition">
<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="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 class="hidden sm:inline">Actualiser</span>
</button>
<button type="button" onclick="ucpLogout()" title="Déconnexion" aria-label="Déconnexion"
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl border border-rose-200 bg-rose-50 hover:bg-rose-100 text-xs font-semibold text-rose-700 transition">
<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 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/></svg>
<span class="hidden sm:inline">Déconnexion</span>
</button>
</div>
</div>
<!-- Navigation Onglets avec dégradé latéral discret sur mobile -->
<div class="relative max-w-6xl mx-auto border-t border-slate-100">
<button type="button" id="ucp-nav-hint-left" onclick="scrollUcpNav(-1)" aria-label="Onglets précédents"
class="hidden absolute left-0 top-0 bottom-0 z-10 w-9 items-center justify-start pl-1 bg-gradient-to-r from-white via-white/95 to-transparent text-slate-400 hover:text-indigo-600 transition">
<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.2" d="M15 19l-7-7 7-7"/></svg>
</button>
<div id="ucp-nav-scroller" class="ucp-nav-scroll px-4 sm:px-6 overflow-x-auto scroll-smooth">
<nav class="flex items-center gap-1 py-2 whitespace-nowrap pr-6 sm:pr-0" id="ucp-nav">
<button type="button" data-ucp-tab="extensions" onclick="switchUcpTab('extensions')"
class="ucp-tab-btn px-3.5 py-2 rounded-xl text-xs font-semibold transition flex items-center gap-2 bg-indigo-50 text-indigo-700">
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3"/></svg>
<span>Mes Postes &amp; Renvois</span>
<span id="ucp-badge-exts" class="px-1.5 py-0.5 rounded-full bg-indigo-100 text-indigo-700 text-[10px] font-bold">0</span>
</button>
<button type="button" data-ucp-tab="voicemail" onclick="switchUcpTab('voicemail')"
class="ucp-tab-btn px-3.5 py-2 rounded-xl text-xs font-semibold transition flex items-center gap-2 text-slate-600 hover:bg-slate-100">
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M21.75 9v.906a2.25 2.25 0 01-1.183 1.981l-6.478 3.488M2.25 9v.906a2.25 2.25 0 001.183 1.981l6.478 3.488m8.839 2.51l-4.66-2.51m0 0l-1.023-.55a2.25 2.25 0 00-2.134 0l-1.022.55m0 0l-4.661 2.51m16.5 1.615a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V8.844a2.25 2.25 0 011.183-1.98l7.5-4.04a2.25 2.25 0 012.134 0l7.5 4.04a2.25 2.25 0 011.183 1.98V19.5z"/></svg>
<span>Messagerie Vocale</span>
<span id="ucp-badge-vm" class="px-1.5 py-0.5 rounded-full bg-slate-200 text-slate-700 text-[10px] font-bold">0</span>
</button>
<button type="button" data-ucp-tab="cdr" onclick="switchUcpTab('cdr')"
class="ucp-tab-btn px-3.5 py-2 rounded-xl text-xs font-semibold transition flex items-center gap-2 text-slate-600 hover:bg-slate-100">
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 6.75c0 8.284 6.716 15 15 15h2.25a2.25 2.25 0 002.25-2.25v-1.372c0-.516-.351-.966-.852-1.091l-4.423-1.106c-.44-.11-.902.055-1.173.417l-.97 1.293c-.282.376-.769.542-1.21.38a12.035 12.035 0 01-7.143-7.143c-.162-.441.004-.928.38-1.21l1.293-.97c.363-.271.527-.734.417-1.173L6.963 3.102a1.125 1.125 0 00-1.091-.852H4.5A2.25 2.25 0 002.25 4.5v2.25z"/></svg>
<span>Mes Appels</span>
</button>
<button type="button" data-ucp-tab="phonebook" onclick="switchUcpTab('phonebook')"
class="ucp-tab-btn px-3.5 py-2 rounded-xl text-xs font-semibold transition flex items-center gap-2 text-slate-600 hover:bg-slate-100">
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25"/></svg>
<span>Annuaire &amp; Profil</span>
</button>
<button type="button" data-ucp-tab="guide" onclick="switchUcpTab('guide')"
class="ucp-tab-btn px-3.5 py-2 rounded-xl text-xs font-semibold transition flex items-center gap-2 text-slate-600 hover:bg-slate-100">
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.8 0A2.251 2.251 0 0113.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25z"/></svg>
<span>Notice &amp; Codes</span>
</button>
<button type="button" data-ucp-tab="account" onclick="switchUcpTab('account')"
class="ucp-tab-btn px-3.5 py-2 rounded-xl text-xs font-semibold transition flex items-center gap-2 text-slate-600 hover:bg-slate-100">
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z"/></svg>
<span>Mon Compte</span>
</button>
</nav>
</div>
<button type="button" id="ucp-nav-hint-right" onclick="scrollUcpNav(1)" aria-label="Onglets suivants"
class="hidden absolute right-0 top-0 bottom-0 z-10 w-10 items-center justify-end pr-1.5 bg-gradient-to-l from-white via-white/95 to-transparent text-slate-400 hover:text-indigo-600 transition">
<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.2" d="M9 5l7 7-7 7"/></svg>
</button>
</div>
</header>
<!-- Contenu principal -->
<main class="flex-1 max-w-6xl w-full mx-auto px-4 sm:px-6 py-6 space-y-6">
<!-- ONGLET 1 : MES POSTES & RENVOIS -->
<section id="ucp-panel-extensions" class="ucp-panel space-y-5">
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div>
<h2 class="text-lg font-bold text-slate-900">Mes Postes &amp; Redirections</h2>
<p class="text-xs text-slate-500">Activez le mode Ne Pas Déranger (DND) ou configurez vos renvois vers un autre poste ou votre messagerie vocale.</p>
</div>
</div>
<div id="ucp-extensions-grid" class="grid grid-cols-1 lg:grid-cols-2 gap-5">
<div class="ucp-card rounded-2xl p-8 text-center text-sm text-slate-400 col-span-full">Chargement de vos postes…</div>
</div>
</section>
<!-- ONGLET 2 : MESSAGERIE VOCALE -->
<section id="ucp-panel-voicemail" class="ucp-panel hidden space-y-5">
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div>
<h2 class="text-lg font-bold text-slate-900">Messagerie Vocale</h2>
<p class="text-xs text-slate-500">Écoutez, téléchargez, archivez ou supprimez vos messages vocaux et gérez votre code PIN.</p>
</div>
<div id="ucp-vm-ext-pills" class="flex flex-wrap gap-2"></div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Liste des messages -->
<div class="lg:col-span-2 space-y-4">
<div class="flex items-center justify-between">
<div class="inline-flex rounded-xl bg-white border border-slate-200 p-1 text-xs font-semibold">
<button type="button" onclick="setVmFolderFilter('all')" id="vm-filter-all" class="px-3 py-1.5 rounded-lg bg-indigo-50 text-indigo-700">Tous</button>
<button type="button" onclick="setVmFolderFilter('INBOX')" id="vm-filter-INBOX" class="px-3 py-1.5 rounded-lg text-slate-600 hover:bg-slate-50">Nouveaux</button>
<button type="button" onclick="setVmFolderFilter('Old')" id="vm-filter-Old" class="px-3 py-1.5 rounded-lg text-slate-600 hover:bg-slate-50">Archivés</button>
</div>
</div>
<div id="ucp-vm-list" class="space-y-3">
<div class="ucp-card rounded-2xl p-8 text-center text-sm text-slate-400">Chargement des messages…</div>
</div>
</div>
<!-- Préférences Boîte Vocale -->
<div class="ucp-card rounded-2xl p-5 h-fit space-y-4">
<div>
<h3 class="text-sm font-bold text-slate-900">Préférences Boîte Vocale</h3>
<p id="ucp-vm-prefs-subtitle" class="text-xs text-slate-500 mt-0.5">Poste sélectionné</p>
</div>
<form id="ucp-vm-prefs-form" class="space-y-3">
<div>
<label class="block text-xs font-semibold text-slate-700 mb-1">Nouveau code PIN (4 à 12 chiffres)</label>
<input type="password" name="pin" inputmode="numeric" pattern="\d{4,12}" placeholder="Laisser vide pour conserver l'actuel"
class="w-full px-3 py-2 rounded-xl bg-slate-50 border border-slate-300 text-xs text-slate-900 font-mono focus:bg-white focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500 focus:outline-none">
</div>
<div>
<label class="block text-xs font-semibold text-slate-700 mb-1">E-mail de notification</label>
<input type="email" name="email" placeholder="prenom@entreprise.fr"
class="w-full px-3 py-2 rounded-xl bg-slate-50 border border-slate-300 text-xs text-slate-900 focus:bg-white focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500 focus:outline-none">
</div>
<button type="submit" id="ucp-vm-prefs-btn"
class="w-full py-2 px-4 rounded-xl bg-indigo-600 hover:bg-indigo-700 text-white text-xs font-semibold transition">
Enregistrer les préférences
</button>
</form>
</div>
</div>
</section>
<!-- ONGLET 3 : MES APPELS (MINI-CDR PERSONNEL) -->
<section id="ucp-panel-cdr" class="ucp-panel hidden space-y-5">
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div>
<h2 class="text-lg font-bold text-slate-900">Mon Journal d'Appels</h2>
<p class="text-xs text-slate-500">Historique filtré sur vos postes liés uniquement.</p>
</div>
<div class="flex flex-wrap items-center gap-2 text-xs">
<span class="px-3 py-1 rounded-full bg-white border border-slate-200 font-semibold text-slate-700">Total : <strong id="ucp-cdr-total">0</strong></span>
<span class="px-3 py-1 rounded-full bg-emerald-50 border border-emerald-200 font-semibold text-emerald-700">Répondus : <strong id="ucp-cdr-answered">0</strong></span>
<span class="px-3 py-1 rounded-full bg-rose-50 border border-rose-200 font-semibold text-rose-700">Manqués : <strong id="ucp-cdr-missed">0</strong></span>
</div>
</div>
<div class="ucp-card rounded-2xl p-4 grid grid-cols-1 sm:grid-cols-4 gap-3">
<select id="ucp-cdr-direction" onchange="loadPersonalCdr()" class="px-3 py-2 rounded-xl bg-slate-50 border border-slate-300 text-xs font-medium text-slate-800">
<option value="all">Toutes directions</option>
<option value="inbound">Appels entrants</option>
<option value="outbound">Appels sortants</option>
<option value="internal">Appels internes</option>
</select>
<select id="ucp-cdr-disposition" onchange="loadPersonalCdr()" class="px-3 py-2 rounded-xl bg-slate-50 border border-slate-300 text-xs font-medium text-slate-800">
<option value="all">Tous statuts</option>
<option value="ANSWERED">Répondus uniquement</option>
<option value="MISSED">Manqués / Sans réponse</option>
</select>
<select id="ucp-cdr-period" onchange="loadPersonalCdr()" class="px-3 py-2 rounded-xl bg-slate-50 border border-slate-300 text-xs font-medium text-slate-800">
<option value="all">Tout l'historique</option>
<option value="today">Aujourd'hui</option>
<option value="week">7 derniers jours</option>
<option value="month">30 derniers jours</option>
</select>
<input type="text" id="ucp-cdr-search" oninput="debounceLoadCdr()" placeholder="Rechercher un numéro ou nom…"
class="px-3 py-2 rounded-xl bg-slate-50 border border-slate-300 text-xs text-slate-900 focus:bg-white focus:outline-none focus:border-indigo-500">
</div>
<!-- Vue mobile style journal d'appels iPhone (< 768px) -->
<div id="ucp-cdr-mobile-list" class="md:hidden space-y-2.5">
<div class="ucp-card rounded-2xl p-6 text-center text-xs text-slate-400">Chargement…</div>
</div>
<!-- Vue tableau complète sur ordinateur (>= 768px) -->
<div class="hidden md:block ucp-card rounded-2xl overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-left text-xs">
<thead class="bg-slate-50 border-b border-slate-200 text-slate-500 uppercase text-[11px]">
<tr>
<th class="py-3 px-4">Date &amp; Heure</th>
<th class="py-3 px-4">Direction</th>
<th class="py-3 px-4">De</th>
<th class="py-3 px-4">Vers</th>
<th class="py-3 px-4">Durée</th>
<th class="py-3 px-4">Statut</th>
</tr>
</thead>
<tbody id="ucp-cdr-tbody" class="divide-y divide-slate-100">
<tr><td colspan="6" class="py-8 text-center text-slate-400">Chargement…</td></tr>
</tbody>
</table>
</div>
</div>
</section>
<!-- ONGLET 4 : ANNUAIRE & PROFIL -->
<section id="ucp-panel-phonebook" class="ucp-panel hidden space-y-5">
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div>
<h2 class="text-lg font-bold text-slate-900">Annuaire de l'Entreprise</h2>
<p class="text-xs text-slate-500">Consultez tous les contacts et mettez à jour la photo ou l'e-mail de vos propres postes.</p>
</div>
<input type="text" id="ucp-pb-search" oninput="renderPhonebookGrid()" placeholder="Rechercher un nom, poste, email…"
class="w-full sm:w-72 px-3.5 py-2 rounded-xl bg-white border border-slate-300 text-xs text-slate-900 focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500 focus:outline-none">
</div>
<div id="ucp-pb-group-tabs" class="flex flex-wrap items-center gap-2"></div>
<div id="ucp-pb-grid" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div class="ucp-card rounded-2xl p-8 text-center text-sm text-slate-400 col-span-full">Chargement de l'annuaire…</div>
</div>
</section>
<!-- ONGLET 5 : NOTICE TÉLÉPHONIE, CODES DE FONCTION & EXPORT PDF -->
<section id="ucp-panel-guide" class="ucp-panel hidden space-y-5">
<!-- Barre d'actions écran uniquement -->
<div class="no-print flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div>
<h2 class="text-lg font-bold text-slate-900">Notice d'Utilisation &amp; Codes de Fonction</h2>
<p class="text-xs text-slate-500">Mémo des raccourcis téléphoniques actifs et fiche pratique imprimable en PDF.</p>
</div>
<div class="flex flex-wrap items-center gap-2.5">
<input type="text" id="ucp-guide-search" oninput="renderFeatureGuide()" placeholder="Filtrer un code ou service (*97, renvoi…)"
class="w-full sm:w-64 px-3.5 py-2 rounded-xl bg-white border border-slate-300 text-xs text-slate-900 focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500 focus:outline-none">
<button type="button" onclick="printFeatureGuide()"
class="inline-flex items-center gap-2 px-4 py-2 rounded-xl bg-indigo-600 hover:bg-indigo-700 text-white text-xs font-semibold shadow-sm shadow-indigo-500/20 transition">
<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="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z"/></svg>
<span>Imprimer / Exporter en PDF</span>
</button>
</div>
</div>
<!-- Document A4 Propre & Imprimable -->
<div id="ucp-guide-printable" class="ucp-card rounded-3xl p-6 sm:p-8 space-y-6">
<!-- En-tête Corporate du document -->
<div class="ucp-print-avoid-break ucp-print-row rounded-2xl bg-gradient-to-r from-slate-900 via-indigo-950 to-slate-900 text-white p-5 sm:p-6 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div class="flex items-center gap-4 min-w-0">
<div class="h-14 w-14 sm:h-16 sm:w-16 rounded-2xl bg-white p-2 flex items-center justify-center shrink-0 shadow-md">
<?php if ($ucpEffectiveLogo !== ''): ?>
<img src="<?= htmlspecialchars($ucpEffectiveLogo) ?>" alt="Logo" class="max-h-full max-w-full object-contain">
<?php else: ?>
<img src="/assets/logo.png?v=1" alt="NexusPBX" class="max-h-full max-w-full object-contain">
<?php endif; ?>
</div>
<div class="min-w-0">
<div class="text-[10px] font-bold uppercase tracking-widest text-indigo-300">Fiche Mémo Utilisateur • Téléphonie IP</div>
<h3 class="text-lg sm:text-xl font-extrabold tracking-tight truncate"><?= htmlspecialchars($companyName ?: 'NexusPBX') ?></h3>
<p class="text-xs text-slate-300 mt-0.5">Guide des codes de fonction, messagerie vocale et renvois d'appel</p>
</div>
</div>
<div class="sm:text-right bg-white/10 border border-white/15 rounded-xl px-4 py-2.5 text-xs space-y-1 shrink-0">
<div class="text-slate-300">Utilisateur : <strong class="text-white"><?= htmlspecialchars((string)($currentUser['display_name'] ?: $currentUser['username'])) ?></strong></div>
<div class="text-slate-300">Poste(s) : <strong id="ucp-guide-user-exts" class="font-mono text-indigo-200">—</strong></div>
<div class="text-[10px] text-slate-400">Édité le <span id="ucp-guide-print-date"><?= date('d/m/Y') ?></span> • v<?= htmlspecialchars($appVersion) ?></div>
</div>
</div>
<!-- Encarts Notice Pratique Rapide -->
<div id="ucp-guide-quickcards" class="ucp-print-3col grid grid-cols-1 md:grid-cols-3 gap-4"></div>
<!-- Liste complète structurée par catégorie -->
<div id="ucp-guide-categories" class="space-y-4">
<div class="text-center text-sm text-slate-400 py-8">Chargement des codes de fonction…</div>
</div>
<!-- Pied de document -->
<div class="ucp-print-avoid-break pt-4 border-t border-slate-200 flex flex-col sm:flex-row sm:items-center justify-between gap-2 text-[11px] text-slate-500">
<div class="flex items-center gap-1.5">
<svg class="w-4 h-4 text-amber-500 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 18v-5.25m0 0a6.01 6.01 0 001.5-.189m-1.5.189a6.01 6.01 0 01-1.5-.189m3.75 7.478a12.06 12.06 0 01-4.5 0m3.75 2.383a14.406 14.406 0 01-3 0M14.25 18v-.192c0-.983.658-1.823 1.508-2.316a7.5 7.5 0 10-7.517 0c.85.493 1.509 1.333 1.509 2.316V18"/></svg>
<span><strong>Astuce :</strong> composez ces codes directement sur votre combiné IP, ou pilotez DND / renvois / messagerie depuis le portail UCP.</span>
</div>
<div class="font-semibold text-slate-600"><?= htmlspecialchars($companyName ?: 'NexusPBX') ?> — Document interne</div>
</div>
</div>
</section>
<!-- ONGLET 6 : MON COMPTE -->
<section id="ucp-panel-account" class="ucp-panel hidden space-y-5">
<div>
<h2 class="text-lg font-bold text-slate-900">Mon Compte UCP</h2>
<p class="text-xs text-slate-500">Sécurisez votre accès personnel au portail utilisateur.</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="ucp-card rounded-2xl p-6 space-y-4">
<h3 class="text-sm font-bold text-slate-900">Informations de session</h3>
<dl class="space-y-2.5 text-xs">
<div class="flex justify-between py-2 border-b border-slate-100">
<dt class="text-slate-500">Identifiant</dt>
<dd class="font-mono font-semibold text-slate-900"><?= htmlspecialchars((string)$currentUser['username']) ?></dd>
</div>
<div class="flex justify-between py-2 border-b border-slate-100">
<dt class="text-slate-500">Nom affiché</dt>
<dd class="font-semibold text-slate-900"><?= htmlspecialchars((string)($currentUser['display_name'] ?: $currentUser['username'])) ?></dd>
</div>
<div class="flex justify-between py-2 border-b border-slate-100">
<dt class="text-slate-500">Postes autorisés</dt>
<dd id="ucp-account-exts" class="font-mono font-semibold text-indigo-600">—</dd>
</div>
<div class="flex justify-between py-2">
<dt class="text-slate-500">Dernière connexion</dt>
<dd class="text-slate-700"><?= htmlspecialchars((string)($currentUser['last_login_at'] ?? 'Première connexion')) ?></dd>
</div>
</dl>
</div>
<div class="ucp-card rounded-2xl p-6 space-y-4">
<h3 class="text-sm font-bold text-slate-900">Changer mon mot de passe UCP</h3>
<form id="ucp-password-form" class="space-y-3">
<div>
<label class="block text-xs font-semibold text-slate-700 mb-1">Mot de passe actuel</label>
<input type="password" name="current_password" required autocomplete="current-password"
class="w-full px-3 py-2 rounded-xl bg-slate-50 border border-slate-300 text-xs text-slate-900 focus:bg-white focus:outline-none focus:border-indigo-500">
</div>
<div>
<label class="block text-xs font-semibold text-slate-700 mb-1">Nouveau mot de passe (min. 6 caractères)</label>
<input type="password" name="new_password" required minlength="6" autocomplete="new-password"
class="w-full px-3 py-2 rounded-xl bg-slate-50 border border-slate-300 text-xs text-slate-900 focus:bg-white focus:outline-none focus:border-indigo-500">
</div>
<div>
<label class="block text-xs font-semibold text-slate-700 mb-1">Confirmer le nouveau mot de passe</label>
<input type="password" name="confirm_password" required minlength="6" autocomplete="new-password"
class="w-full px-3 py-2 rounded-xl bg-slate-50 border border-slate-300 text-xs text-slate-900 focus:bg-white focus:outline-none focus:border-indigo-500">
</div>
<button type="submit" class="w-full py-2.5 px-4 rounded-xl bg-indigo-600 hover:bg-indigo-700 text-white text-xs font-semibold transition">
Mettre à jour mon mot de passe
</button>
</form>
</div>
</div>
</section>
</main>
</div>
<!-- MODALE ÉDITION PROFIL ANNUAIRE (PHOTO + EMAIL UNIQUEMENT) -->
<div id="ucp-profile-modal" class="hidden fixed inset-0 z-50 bg-slate-900/40 backdrop-blur-sm flex items-center justify-center p-4">
<div class="w-full max-w-md bg-white rounded-2xl border border-slate-200 shadow-2xl p-6 space-y-5">
<div class="flex items-center justify-between">
<div>
<h3 class="text-base font-bold text-slate-900">Modifier ma fiche annuaire</h3>
<p id="ucp-profile-modal-sub" class="text-xs text-slate-500">Poste —</p>
</div>
<button type="button" onclick="closeProfileModal()" class="text-slate-400 hover:text-slate-700 text-lg font-bold">✕</button>
</div>
<div class="flex items-center gap-4 p-3.5 rounded-xl bg-slate-50 border border-slate-200">
<img id="ucp-profile-preview" src="" alt="Avatar" class="w-14 h-14 rounded-full object-cover border border-slate-200 bg-white hidden">
<div id="ucp-profile-initials" class="w-14 h-14 rounded-full bg-indigo-100 text-indigo-700 font-bold flex items-center justify-center text-lg">?</div>
<div class="flex-1 min-w-0">
<label class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-white border border-slate-300 hover:bg-slate-100 text-xs font-semibold text-slate-700 cursor-pointer transition">
<svg class="w-4 h-4 text-slate-500 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6.827 6.175A2.31 2.31 0 015.186 7.23c-.38.054-.757.112-1.134.175C2.999 7.58 2.25 8.507 2.25 9.574V18a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9.574c0-1.067-.75-1.994-1.802-2.169a47.865 47.865 0 00-1.134-.175 2.31 2.31 0 01-1.64-1.055l-.822-1.316a2.192 2.192 0 00-1.736-1.039 48.774 48.774 0 00-5.232 0 2.192 2.192 0 00-1.736 1.039l-.821 1.316z"/><path stroke-linecap="round" stroke-linejoin="round" d="M16.5 12.75a4.5 4.5 0 11-9 0 4.5 4.5 0 019 0z"/></svg>
<span>Choisir une photo</span>
<input type="file" id="ucp-avatar-input" accept="image/jpeg,image/png,image/webp" class="hidden" onchange="uploadSelectedAvatar()">
</label>
<p class="text-[11px] text-slate-400 mt-1">JPG, PNG ou WebP • 5 Mo max.</p>
</div>
</div>
<form id="ucp-profile-email-form" class="space-y-3">
<input type="hidden" id="ucp-profile-ext" value="">
<div>
<label class="block text-xs font-semibold text-slate-700 mb-1">Adresse e-mail affichée dans l'annuaire</label>
<input type="email" id="ucp-profile-email-input" placeholder="prenom.nom@entreprise.fr"
class="w-full px-3.5 py-2 rounded-xl bg-slate-50 border border-slate-300 text-xs text-slate-900 focus:bg-white focus:outline-none focus:border-indigo-500">
</div>
<div class="flex gap-2 pt-2">
<button type="button" onclick="closeProfileModal()" class="flex-1 py-2 px-4 rounded-xl border border-slate-200 text-xs font-semibold text-slate-600 hover:bg-slate-50">Fermer</button>
<button type="submit" class="flex-1 py-2 px-4 rounded-xl bg-indigo-600 hover:bg-indigo-700 text-white text-xs font-semibold">Enregistrer l'e-mail</button>
</div>
</form>
</div>
</div>
<?php endif; ?>
<script>
window.UCP_CSRF_TOKEN = <?= json_encode($csrfToken) ?>;
window.UCP_IS_AUTH = <?= json_encode($isAuth) ?>;
</script>
<script src="/assets/ucp.js?v=<?= file_exists(__DIR__ . '/assets/ucp.js') ? filemtime(__DIR__ . '/assets/ucp.js') : time() ?>"></script>
</body>
</html>
+42 -7
View File
@@ -1,12 +1,47 @@
{
"version": "1.3.7",
"codename": "Helios",
"release_date": "2026-11-04",
"version": "2.7.1",
"codename": "Pulsar UCP",
"release_date": "2026-11-12",
"min_php_version": "8.2",
"changelog": [
"Vérification ultra-rapide des mises à jour en direct via cURL CLI et flux HTTP sans dépendre de git ou de php-curl",
"Bouton 'Vérifier les mises à jour' forçant le rafraîchissement immédiat du cache navigateur (bypass 24h)",
"Nouvelle modale de confirmation de restauration système alignée sur le thème sombre NexusPBX",
"Remplacement des popups d'alerte natives du navigateur lors des restaurations locales et téléversées"
{
"version": "2.7.1",
"date": "2026-11-12",
"changes": [
"Mise à jour 1-Clic : Suppression de l'erreur Nginx 502 transitoire après mise à jour (pause du polling métriques, attente du reload PHP-FPM/Nginx avec sonde multi-essais avant rechargement de la page, fermeture stdin dans update.sh)"
]
},
{
"version": "2.7.0",
"date": "2026-11-12",
"changes": [
"Branding UCP : Ajout d'un logo spécifique optionnel pour le portail UCP (thème clair), avec repli automatique sur le logo principal puis sur le logo NexusPBX par défaut"
]
},
{
"version": "2.6.7",
"date": "2026-11-12",
"changes": [
"UI Admin & UCP : Remplacement de l'ensemble des icônes emoji par des icônes vectorielles SVG uniformes (style Heroicons), sans modifier le menu latéral Admin",
"Correction d'un problème de syntaxe JS sur le badge d'avertissement TLS dans app.js"
]
},
"UI v2.6.6 : menu déroulant personnalisé sombre avec icônes SVG Heroicons pour toutes les destinations d'appel (sans emojis natifs)",
"UI v2.6.5 : refonte des intitulés de destinations audio (Musique d'attente & Annonce vocale en boucle continue), nouveau wallpaper clair UCP et placeholder générique",
"Correctif v2.6.4 : prise en charge multi-groupes dans l'annuaire UCP & Admin (un poste appartenant à plusieurs groupes apparaît bien dans chacun des onglets correspondants)",
"UI v2.6.3 : onglets dynamiques par groupe (Tous, Général, Paris, New-York...) avec compteurs dans l'Annuaire UCP",
"Correctif v2.6.2 : sélection stricte de <host> GroundWire (NAT si renseigné, sinon VIP HA si cluster actif, sinon IP locale + port d'écoute local)",
"UI v2.6.1 : suppression de l'icône smartphone sur les éléments GroundWire et refonte des boutons d'action du tableau Postes (Redémarrer / Modifier / Supprimer)",
"Nouveauté v2.6.0 : provisionnement dynamique XML Acrobits GroundWire par poste (option Admin « GroundWire XML », deep link mobile provlinkbs:, téléchargement XML signé HMAC sans session, liens App Store / Google Play dans l'UCP)",
"UI v2.5.7 : journal d'appels UCP en cartes style iOS sur mobile (<768px) tout en conservant le tableau 6 colonnes sur ordinateur",
"Correctif v2.5.6 : ergonomie mobile iPhone (en-tête compact + dégradé latéral discret sur la barre d'onglets UCP sans scrollbar visible)",
"Correctif v2.5.5 : suppression du zoom automatique iOS/iPhone sur les champs (<16px) et blocage du débordement horizontal mobile dans l'UCP",
"Correctif v2.5.4 : correction du journal personnel UCP « Mes Appels » (correspondance caller_number/callee_number/canaux PJSIP et filtres Répondus/Manqués)",
"Nouveauté v2.5.3 : onglet UCP « Notice & Codes » avec mémo pas-à-pas, codes de fonction dynamiques et export PDF / impression A4 corporate (logo, encarts, poste utilisateur)",
"Correctif v2.5.2 : purge systématique try/finally + auto-nettoyage des dossiers temporaires HA /tmp (élimine la saturation tmpfs 100% qui bloquait la réplication)",
"Correctif v2.5.1 : auto-déploiement automatique Nginx/Fail2ban UCP même après un git pull manuel",
"Correctif v2.5.1 : vérification d'intégrité & restauration SQLite HA via PDO / SQLite3::backup sans dépendance CLI sqlite3 et anti-collision /tmp",
"Correctif v2.5.1 : rafraîchissement instantané de la liste des utilisateurs UCP à la création/suppression sans rechargement manuel de la page",
"NexusPBX 2.5.0 « Pulsar UCP » (Issue #4) : Portail Utilisateur Isolé (User Control Panel)"
]
}