Compare commits

...
109 Commits
Author SHA1 Message Date
sorcier e9aef1ff3e Merge branch 'dev' 2026-11-12 12:09:16 +01:00
sorcier bfc71a7763 fix(phonebook): v2.7.3 preserve contact email on syncExtensions 2026-11-12 12:08:07 +01:00
sorcier ee5f6b4aa6 Merge branch 'dev' 2026-11-12 11:57:59 +01:00
sorcier f99cf440a7 fix(version): v2.7.2 uniform string changelog in version.json (fix [object Object]) 2026-11-12 11:57:57 +01:00
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 9709 additions and 529 deletions

No files matched your search

+7
View File
@@ -5,6 +5,8 @@
/storage/*.sqlite-shm /storage/*.sqlite-shm
/storage/*.sqlite-wal /storage/*.sqlite-wal
/storage/*.prerestore.* /storage/*.prerestore.*
/storage/*.bak
*.bak
# Données dynamiques et métriques # Données dynamiques et métriques
/storage/metrics_prev.json /storage/metrics_prev.json
@@ -38,3 +40,8 @@ Thumbs.db
.idea/ .idea/
.vscode/ .vscode/
# Environnement local de développement (exclu de Git)
/.dev
/.env
/.env.local
+12 -8
View File
@@ -47,30 +47,34 @@
```text ```text
nexus-pbx/ nexus-pbx/
├── install.sh # Script d'installation automatisé tout-en-un (Debian 12 / 13)
├── public/ # Racine Web Nginx ├── public/ # Racine Web Nginx
│ ├── index.php # Interface Web SPA moderne │ ├── index.php # Interface Web SPA moderne
│ ├── api.php # API RESTful backend │ ├── api.php # API RESTful backend
│ ├── phonebook.php # Générateur XML Yealink Remote Phonebook │ ├── 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 │ └── avatars/ # Photos des contacts de l'annuaire
├── src/ # Classes PHP d'orchestration ├── src/ # Classes PHP d'orchestration
│ ├── AmiClient.php # Client Asterisk Manager Interface (AMI) │ ├── AmiClient.php # Client Asterisk Manager Interface (AMI)
│ ├── Auth.php # Authentification & sessions │ ├── Auth.php # Authentification & sessions
│ ├── BackupManager.php # Moteur de sauvegarde locale & distante SSH │ ├── BackupManager.php # Moteur Disaster Recovery & sauvegardes
│ ├── CertificateManager.php # Gestionnaire SSL & Certbot │ ├── CdrManager.php # Moteur Journal d'appels & CDR
│ ├── CertificateManager.php # Gestionnaire SSL & Certbot ACME
│ ├── ConfigGenerator.php # Générateur dynamique de conf Asterisk (pjsip, extensions) │ ├── ConfigGenerator.php # Générateur dynamique de conf Asterisk (pjsip, extensions)
│ ├── Database.php # Connexion PDO SQLite │ ├── 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 │ ├── PhonebookManager.php # Moteur d'annuaire et export XML
│ ├── SystemChecker.php # Diagnostic de santé système │ ├── SystemChecker.php # Diagnostic de santé système
│ └── SystemMetrics.php # Monitoring CPU, RAM, Disque, Asterisk │ └── SystemMetrics.php # Monitoring CPU, RAM, Disque, Asterisk
├── scripts/ # Scripts CLI et daemons système ├── scripts/ # Scripts CLI et daemons système
│ ├── setup-debian.sh # Script d'installation automatisé Debian 12 / 13 │ ├── update.sh # Script de mise à jour OTA (nexus-update)
│ ├── nexus-firewall # Script de gestion iptables/nftables │ ├── nexus-firewall # Outil CLI Pare-feu & Fail2ban
│ └── nexus_autobackup.php# Cronjob de sauvegarde automatique │ ├── 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) └── storage/ # Données persistantes (exclues du Git)
├── database.sqlite # Base de données applicative ├── database.sqlite # Base de données applicative
├── backups/ # Archives de sauvegarde ├── system_backups/ # Archives de sauvegarde Disaster Recovery
└── sounds/ # Fichiers audio MoH et annonces └── sounds/ # Fichiers audio MoH et annonces
``` ```
+130 -30
View File
@@ -64,6 +64,11 @@ apt-get install -y --no-install-recommends \
sox \ sox \
sshpass 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 # Vérification et installation d'Asterisk
echo -e "${C_GREEN} -> Installation du moteur Asterisk PBX...${C_RESET}" 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 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 apt-get install -y -t sid asterisk
fi fi
# Sons Asterisk en français (exacts de la machine de dev) # 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 2>/dev/null || true 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 # Nginx, PHP-FPM et extensions requises
apt-get install -y --no-install-recommends \ 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}" echo -e "${C_GREEN}[2/8] Préparation et déploiement des fichiers NexusPBX...${C_RESET}"
TARGET_DIR="/var/www/nexus-pbx" TARGET_DIR="/var/www/nexus-pbx"
INSTALL_BRANCH="${1:-${INSTALL_BRANCH:-dev}}"
if [ -d "$TARGET_DIR/.git" ]; then 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" cd "$TARGET_DIR"
git config --global --add safe.directory "$TARGET_DIR" 2>/dev/null || true 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 elif [ -d "$PWD/public" ] && [ -d "$PWD/src" ]; then
echo " -> Exécution depuis le dossier source, synchronisation vers $TARGET_DIR..." echo " -> Exécution depuis le dossier source, synchronisation vers $TARGET_DIR..."
if [ "$PWD" != "$TARGET_DIR" ]; then if [ "$PWD" != "$TARGET_DIR" ]; then
@@ -139,13 +147,17 @@ elif [ -d "$PWD/public" ] && [ -d "$PWD/src" ]; then
cd "$TARGET_DIR" cd "$TARGET_DIR"
fi fi
else 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" 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 git config --system --add safe.directory "$TARGET_DIR" 2>/dev/null || true
cd "$TARGET_DIR" cd "$TARGET_DIR"
fi fi
if [ "$INSTALL_BRANCH" = "dev" ]; then
touch "$TARGET_DIR/.dev"
fi
# 4. Configuration des utilisateurs, groupes et arborescence de stockage # 4. Configuration des utilisateurs, groupes et arborescence de stockage
echo -e "${C_GREEN}[3/8] Configuration des permissions et de l'arborescence...${C_RESET}" echo -e "${C_GREEN}[3/8] Configuration des permissions et de l'arborescence...${C_RESET}"
usermod -aG asterisk www-data 2>/dev/null || true 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 /etc/asterisk/keys
mkdir -p /var/lib/asterisk/sounds/custom mkdir -p /var/lib/asterisk/sounds/custom
mkdir -p /var/spool/asterisk/voicemail/default 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 /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 touch /var/log/asterisk/messages /var/log/asterisk/security.log /var/log/asterisk/cdr-csv/Master.csv
chown asterisk:asterisk /var/log/asterisk/messages /var/log/asterisk/security.log 2>/dev/null || true chown -R asterisk:asterisk /var/log/asterisk 2>/dev/null || true
chmod 0664 /var/log/asterisk/messages /var/log/asterisk/security.log 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 touch /var/log/nexus_security.log /var/log/nexus_ucp_security.log
chown www-data:asterisk /var/log/nexus_security.log 2>/dev/null || true 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 2>/dev/null || true chmod 0664 /var/log/nexus_security.log /var/log/nexus_ucp_security.log 2>/dev/null || true
# Droits applicatifs stricts # Droits applicatifs stricts
chown -R www-data:asterisk "$TARGET_DIR" chown -R www-data:asterisk "$TARGET_DIR"
@@ -177,19 +195,20 @@ chmod 2775 "$TARGET_DIR/storage" \
"$TARGET_DIR/storage/system_backups" \ "$TARGET_DIR/storage/system_backups" \
"$TARGET_DIR/storage/sounds" \ "$TARGET_DIR/storage/sounds" \
"$TARGET_DIR/public/avatars" "$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 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 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 -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 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 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 -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 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) # Droits sudoers pour l'interface web (www-data)
cat << 'EOF' > /etc/sudoers.d/nexus-pbx cat << 'EOF' > /etc/sudoers.d/nexus-pbx
# Privilèges spécifiques NexusPBX pour l'utilisateur Web www-data # 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 EOF
chmod 0440 /etc/sudoers.d/nexus-pbx 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" cat << 'EOF_OVERRIDE' > "/etc/systemd/system/${svc_name}.d/override.conf"
[Service] [Service]
ProtectSystem=false 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 EOF_OVERRIDE
fi fi
done done
systemctl daemon-reload 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) # Configuration des limites d'upload PHP (Restauration d'archives 512 Mo & Sons volumineux)
for php_dir in /etc/php/*; do for php_dir in /etc/php/*; do
if [ -d "$php_dir/fpm/conf.d" ]; then if [ -d "$php_dir/fpm/conf.d" ]; then
@@ -287,23 +311,61 @@ touch /etc/asterisk/pjsip.conf \
/etc/asterisk/pjsip.custom.conf \ /etc/asterisk/pjsip.custom.conf \
/etc/asterisk/extensions.conf \ /etc/asterisk/extensions.conf \
/etc/asterisk/extensions.custom.conf \ /etc/asterisk/extensions.custom.conf \
/etc/asterisk/musiconhold.conf \
/etc/asterisk/musiconhold.custom.conf \ /etc/asterisk/musiconhold.custom.conf \
/etc/asterisk/voicemail.conf \
/etc/asterisk/voicemail.custom.conf \ /etc/asterisk/voicemail.custom.conf \
/etc/asterisk/modules.conf \
/etc/asterisk/rtp.conf \ /etc/asterisk/rtp.conf \
/etc/asterisk/features.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 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 printf '\n#tryinclude "pjsip.custom.conf"\n' >> /etc/asterisk/pjsip.conf
fi fi
if ! grep -q 'extensions.custom.conf' /etc/asterisk/extensions.conf 2>/dev/null; then 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 printf '\n#tryinclude "extensions.custom.conf"\n' >> /etc/asterisk/extensions.conf
fi 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 0664 /etc/asterisk/*.conf /etc/asterisk/*.custom.conf 2>/dev/null || true
chmod 2750 /etc/asterisk/keys chmod 2750 /etc/asterisk/keys
chmod 2775 /etc/asterisk 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 2>/dev/null || true 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 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 # Rechargement Asterisk immédiat pour appliquer AMI et Logger
asterisk -rx "core reload" 2>/dev/null || true 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 rm -f /etc/nginx/sites-enabled/default
ln -sf /etc/nginx/sites-available/nexus-pbx /etc/nginx/sites-enabled/nexus-pbx 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 ' php -r '
require_once "/var/www/nexus-pbx/src/Database.php"; require_once "/var/www/nexus-pbx/src/Database.php";
require_once "/var/www/nexus-pbx/src/FirewallManager.php"; require_once "/var/www/nexus-pbx/src/FirewallManager.php";
$db = App\Database::getConnection(); $st = App\FirewallManager::getSettings();
$st = $db->query("SELECT admin_https_port, certbot_strict_port80 FROM settings WHERE id = 1")->fetch(PDO::FETCH_ASSOC); App\FirewallManager::ensureUcpFail2banArtifacts();
App\FirewallManager::applyNginxConfig((int)($st["admin_https_port"] ?? 8443), (int)($st["certbot_strict_port80"] ?? 0)); 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 cat << 'EOF' > /etc/fail2ban/filter.d/nexus-web.conf
[Definition] [Definition]
failregex = ^.*\[NEXUS-WEB-AUTH-FAIL\]:\s*<HOST>.*$ failregex = ^.*\[NEXUS-WEB-AUTH-FAIL\]:\s*<HOST>.*$
@@ -404,7 +472,12 @@ App\FirewallManager::generateJailLocal(
(int)$st["fail2ban_sip_bantime"], (int)$st["fail2ban_sip_bantime"],
(int)$st["fail2ban_web_maxretry"], (int)$st["fail2ban_web_maxretry"],
(int)$st["fail2ban_web_bantime"], (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 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 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 # 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-firewall" /usr/local/bin/nexus-firewall
ln -sf "$TARGET_DIR/scripts/nexus-banner.sh" /usr/local/bin/nexus-banner 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/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) # Bannière interactive lors de chaque connexion terminal (SSH / Console)
cat << 'EOF' > /etc/profile.d/nexus-motd.sh 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}" echo -e "${C_GREEN}[8/8] Démarrage et activation des services système...${C_RESET}"
systemctl daemon-reload 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) # Redémarrage PHP-FPM (rechargement de l'appartenance au groupe asterisk)
systemctl restart php*-fpm 2>/dev/null || true 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") 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/BackupManager.php';
require_once __DIR__ . '/../src/FirewallManager.php'; require_once __DIR__ . '/../src/FirewallManager.php';
require_once __DIR__ . '/../src/PhonebookManager.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\Database;
use App\Auth; use App\Auth;
@@ -22,13 +35,15 @@ use App\CertificateManager;
use App\BackupManager; use App\BackupManager;
use App\FirewallManager; use App\FirewallManager;
use App\PhonebookManager; use App\PhonebookManager;
use App\CdrManager;
use App\ClusterManager;
use App\UcpManager;
header('Content-Type: application/json; charset=utf-8'); header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store, no-cache, must-revalidate'); header('Cache-Control: no-store, no-cache, must-revalidate');
try { try {
$db = Database::getConnection(); $db = Database::getConnection();
Auth::startSession();
$action = trim((string)($_GET['action'] ?? '')); $action = trim((string)($_GET['action'] ?? ''));
$rawBody = file_get_contents('php://input'); $rawBody = file_get_contents('php://input');
@@ -37,6 +52,39 @@ try {
$payload = $_POST; $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) // Actions publiques (Login / Logout)
if ($action === 'login') { if ($action === 'login') {
$username = trim((string)($payload['username'] ?? '')); $username = trim((string)($payload['username'] ?? ''));
@@ -50,10 +98,7 @@ try {
]); ]);
} else { } else {
// Journalisation de sécurité pour Fail2ban (jail nexus-web) // Journalisation de sécurité pour Fail2ban (jail nexus-web)
$clientIp = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1'; $clientIp = FirewallManager::resolveClientIp();
if (str_contains($clientIp, ',')) {
$clientIp = trim(explode(',', $clientIp)[0]);
}
$logMsg = date('[Y-m-d H:i:s] ') . "[NEXUS-WEB-AUTH-FAIL]: " . $clientIp . " (username: " . ($username ?: 'vide') . ")\n"; $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); @file_put_contents('/var/log/nexus_security.log', $logMsg, FILE_APPEND | LOCK_EX);
@@ -72,6 +117,7 @@ try {
exit; exit;
} }
// Toutes les autres actions requièrent une session authentifiée // Toutes les autres actions requièrent une session authentifiée
if (!Auth::isAuthenticated()) { if (!Auth::isAuthenticated()) {
http_response_code(401); http_response_code(401);
@@ -87,6 +133,9 @@ try {
switch ($action) { switch ($action) {
case 'get_state': case 'get_state':
try {
FirewallManager::ensureUcpDeployed();
} catch (\Throwable) {}
$settings = $db->query('SELECT * FROM settings WHERE id = 1')->fetch(); $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(); $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(); $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']]; $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([ echo json_encode([
'success' => true, 'success' => true,
'version' => $installedVer,
'user' => Auth::getUsername(), 'user' => Auth::getUsername(),
'settings' => $settings, 'settings' => $settings,
'extensions' => $extensions, 'extensions' => $extensions,
@@ -156,6 +212,7 @@ try {
'backups_count' => count(BackupManager::listBackups($db)), 'backups_count' => count(BackupManager::listBackups($db)),
'phonebook_contacts' => PhonebookManager::getContacts($db), 'phonebook_contacts' => PhonebookManager::getContacts($db),
'phonebook_groups' => PhonebookManager::getGroups($db), 'phonebook_groups' => PhonebookManager::getGroups($db),
'ucp_users' => UcpManager::listUsers($db),
'metrics' => SystemMetrics::collect($db), 'metrics' => SystemMetrics::collect($db),
'previews' => [ 'previews' => [
'pjsip' => $generator->generatePjsipConf(), 'pjsip' => $generator->generatePjsipConf(),
@@ -219,8 +276,15 @@ try {
@chgrp($webLog, 'asterisk'); @chgrp($webLog, 'asterisk');
@chmod($webLog, 0664); @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'); @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([ echo json_encode([
'success' => true, 'success' => true,
@@ -247,6 +311,7 @@ try {
$voicemail_attach = isset($payload['voicemail_attach']) ? (int)!empty($payload['voicemail_attach']) : 1; $voicemail_attach = isset($payload['voicemail_attach']) ? (int)!empty($payload['voicemail_attach']) : 1;
$voicemail_delete = isset($payload['voicemail_delete']) ? (int)!empty($payload['voicemail_delete']) : 0; $voicemail_delete = isset($payload['voicemail_delete']) ? (int)!empty($payload['voicemail_delete']) : 0;
$ring_time = max(5, min(120, (int)($payload['ring_time'] ?? 20))); $ring_time = max(5, min(120, (int)($payload['ring_time'] ?? 20)));
$groundwire_enabled = !empty($payload['groundwire_enabled']) ? 1 : 0;
if ($force_pin_change === 1) { if ($force_pin_change === 1) {
$secFile = "/var/spool/asterisk/voicemail/default/{$extension}/secret.conf"; $secFile = "/var/spool/asterisk/voicemail/default/{$extension}/secret.conf";
@@ -275,7 +340,7 @@ try {
encryption = :encryption, codecs = :codecs, max_contacts = :max_contacts, enabled = :enabled, encryption = :encryption, codecs = :codecs, max_contacts = :max_contacts, enabled = :enabled,
voicemail_enabled = :vme, voicemail_pin = :vmp, force_pin_change = :fpc, voicemail_enabled = :vme, voicemail_pin = :vmp, force_pin_change = :fpc,
voicemail_skip_pin = :vsp, voicemail_email = :vme_mail, voicemail_attach = :vma, 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 WHERE id = :id
'); ');
$stmt->execute([ $stmt->execute([
@@ -296,14 +361,15 @@ try {
':vma' => $voicemail_attach, ':vma' => $voicemail_attach,
':vmd' => $voicemail_delete, ':vmd' => $voicemail_delete,
':rt' => $ring_time, ':rt' => $ring_time,
':gwe' => $groundwire_enabled,
]); ]);
} else { } else {
$stmt = $db->prepare(' $stmt = $db->prepare('
INSERT INTO extensions (extension, name, secret, transport, encryption, codecs, max_contacts, enabled, INSERT INTO extensions (extension, name, secret, transport, encryption, codecs, max_contacts, enabled,
voicemail_enabled, voicemail_pin, force_pin_change, voicemail_skip_pin, 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, 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 ON CONFLICT(extension) DO UPDATE SET
name = excluded.name, name = excluded.name,
secret = excluded.secret, secret = excluded.secret,
@@ -319,7 +385,8 @@ try {
voicemail_email = excluded.voicemail_email, voicemail_email = excluded.voicemail_email,
voicemail_attach = excluded.voicemail_attach, voicemail_attach = excluded.voicemail_attach,
voicemail_delete = excluded.voicemail_delete, voicemail_delete = excluded.voicemail_delete,
ring_time = excluded.ring_time ring_time = excluded.ring_time,
groundwire_enabled = excluded.groundwire_enabled
'); ');
$stmt->execute([ $stmt->execute([
':ext' => $extension, ':ext' => $extension,
@@ -338,6 +405,7 @@ try {
':vma' => $voicemail_attach, ':vma' => $voicemail_attach,
':vmd' => $voicemail_delete, ':vmd' => $voicemail_delete,
':rt' => $ring_time, ':rt' => $ring_time,
':gwe' => $groundwire_enabled,
]); ]);
} }
@@ -362,6 +430,13 @@ try {
// Synchronisation automatique de l'annuaire d'entreprise // Synchronisation automatique de l'annuaire d'entreprise
PhonebookManager::syncExtensions($db); 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)'); $modeBadge = $transport === 'tls' ? 'Distant (TLS + SRTP)' : ($encryption ? 'Interne (UDP + SRTP)' : 'Interne (UDP)');
echo json_encode([ echo json_encode([
'success' => true, 'success' => true,
@@ -374,12 +449,100 @@ try {
$stmt = $db->prepare('DELETE FROM extensions WHERE id = :id'); $stmt = $db->prepare('DELETE FROM extensions WHERE id = :id');
$stmt->execute([':id' => $id]); $stmt->execute([':id' => $id]);
PhonebookManager::syncExtensions($db); PhonebookManager::syncExtensions($db);
$generator->writeAllConfigs();
try {
$ami = AmiClient::fromDatabase($db);
$ami->reloadPjsipAndDialplan();
} catch (\Throwable $e) {}
echo json_encode([ echo json_encode([
'success' => true, 'success' => true,
'message' => 'Poste téléphonique supprimé de la base SQLite.', 'message' => 'Poste téléphonique supprimé de la base SQLite.',
]); ]);
break; 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': case 'save_group':
$id = isset($payload['id']) && $payload['id'] !== '' ? (int)$payload['id'] : null; $id = isset($payload['id']) && $payload['id'] !== '' ? (int)$payload['id'] : null;
$extension = trim((string)($payload['extension'] ?? '')); $extension = trim((string)($payload['extension'] ?? ''));
@@ -395,6 +558,14 @@ try {
if ($name === '') { if ($name === '') {
throw new InvalidArgumentException("Le nom du groupe d'appel est obligatoire."); 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) { if ($id) {
$stmt = $db->prepare(' $stmt = $db->prepare('
@@ -433,6 +604,12 @@ try {
]); ]);
} }
$generator->writeAllConfigs();
try {
$ami = AmiClient::fromDatabase($db);
$ami->reloadPjsipAndDialplan();
} catch (\Throwable $e) {}
echo json_encode([ echo json_encode([
'success' => true, 'success' => true,
'message' => "Groupe d'appel {$extension} ({$name}) sauvegardé.", 'message' => "Groupe d'appel {$extension} ({$name}) sauvegardé.",
@@ -442,6 +619,12 @@ try {
case 'delete_group': case 'delete_group':
$id = (int)($payload['id'] ?? 0); $id = (int)($payload['id'] ?? 0);
$db->prepare('DELETE FROM groups WHERE id = :id')->execute([':id' => $id]); $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([ echo json_encode([
'success' => true, 'success' => true,
'message' => "Groupe de sonnerie supprimé.", '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([ echo json_encode([
'success' => true, 'success' => true,
'message' => "Groupe de téléavertissement {$extension} ({$name}) sauvegardé.", 'message' => "Groupe de téléavertissement {$extension} ({$name}) sauvegardé.",
@@ -542,6 +731,12 @@ try {
case 'delete_paging_group': case 'delete_paging_group':
$id = (int)($payload['id'] ?? 0); $id = (int)($payload['id'] ?? 0);
$db->prepare('DELETE FROM paging_groups WHERE id = :id')->execute([':id' => $id]); $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([ echo json_encode([
'success' => true, 'success' => true,
'message' => "Groupe de téléavertissement supprimé.", 'message' => "Groupe de téléavertissement supprimé.",
@@ -1108,6 +1303,7 @@ try {
$send_rpid = (int)!empty($payload['send_rpid']); $send_rpid = (int)!empty($payload['send_rpid']);
$send_diversion = isset($payload['send_diversion']) ? (int)!empty($payload['send_diversion']) : 1; $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_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)); $max_retries = max(0, (int)($payload['max_retries'] ?? 10000));
$forbidden_retry_interval = max(0, (int)($payload['forbidden_retry_interval'] ?? 600)); $forbidden_retry_interval = max(0, (int)($payload['forbidden_retry_interval'] ?? 600));
$fatal_retry_interval = max(0, (int)($payload['fatal_retry_interval'] ?? 0)); $fatal_retry_interval = max(0, (int)($payload['fatal_retry_interval'] ?? 0));
@@ -1181,6 +1377,7 @@ try {
':send_rpid' => $send_rpid, ':send_rpid' => $send_rpid,
':send_diversion' => $send_diversion, ':send_diversion' => $send_diversion,
':trust_id_inbound' => $trust_id_inbound, ':trust_id_inbound' => $trust_id_inbound,
':trust_id_outbound' => $trust_id_outbound,
':codecs' => $codecs, ':codecs' => $codecs,
':dtmf_mode' => $dtmf_mode, ':dtmf_mode' => $dtmf_mode,
':media_encryption' => $media_encryption, ':media_encryption' => $media_encryption,
@@ -1222,7 +1419,7 @@ try {
fatal_retry_interval = :fatal_retry_interval, line_support = :line_support, fatal_retry_interval = :fatal_retry_interval, line_support = :line_support,
from_user = :from_user, from_domain = :from_domain, outbound_callerid = :outbound_callerid, from_user = :from_user, from_domain = :from_domain, outbound_callerid = :outbound_callerid,
send_pai = :send_pai, send_rpid = :send_rpid, send_diversion = :send_diversion, 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, media_encryption = :media_encryption, nat_symmetric = :nat_symmetric,
direct_media = :direct_media, qualify_frequency = :qualify_frequency, direct_media = :direct_media, qualify_frequency = :qualify_frequency,
max_channels = :max_channels, timers = :timers, language = :language, 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, registration_mode, register, server_uri, client_uri, contact_user, expiration, retry_interval,
max_retries, forbidden_retry_interval, fatal_retry_interval, line_support, max_retries, forbidden_retry_interval, fatal_retry_interval, line_support,
from_user, from_domain, outbound_callerid, send_pai, send_rpid, send_diversion, 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, qualify_frequency, max_channels, timers, language, rtp_keepalive, t38_udptl,
fax_detect, inband_progress, match_ips, context, inbound_dest, did_routes_json, 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, 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, :registration_mode, :register, :server_uri, :client_uri, :contact_user, :expiration, :retry_interval,
:max_retries, :forbidden_retry_interval, :fatal_retry_interval, :line_support, :max_retries, :forbidden_retry_interval, :fatal_retry_interval, :line_support,
:from_user, :from_domain, :outbound_callerid, :send_pai, :send_rpid, :send_diversion, :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, :qualify_frequency, :max_channels, :timers, :language, :rtp_keepalive, :t38_udptl,
:fax_detect, :inband_progress, :match_ips, :context, :inbound_dest, :did_routes_json, :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, :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, 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, 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, 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, media_encryption = excluded.media_encryption, nat_symmetric = excluded.nat_symmetric,
direct_media = excluded.direct_media, qualify_frequency = excluded.qualify_frequency, direct_media = excluded.direct_media, qualify_frequency = excluded.qualify_frequency,
max_channels = excluded.max_channels, timers = excluded.timers, language = excluded.language, max_channels = excluded.max_channels, timers = excluded.timers, language = excluded.language,
@@ -1605,30 +1802,53 @@ try {
case 'save_company_branding': case 'save_company_branding':
$company_name = trim((string)($payload['company_name'] ?? '')); $company_name = trim((string)($payload['company_name'] ?? ''));
$company_logo = trim((string)($payload['company_logo'] ?? '')); $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)."); 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)."); 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)) { $sets = [];
$stmt = $db->prepare('UPDATE settings SET company_name = :cn, company_logo = :cl, updated_at = CURRENT_TIMESTAMP WHERE id = 1'); $params = [];
$stmt->execute([':cn' => $company_name, ':cl' => $company_logo]); if (array_key_exists('company_name', $payload)) {
} elseif (array_key_exists('company_logo', $payload)) { $sets[] = 'company_name = :cn';
$stmt = $db->prepare('UPDATE settings SET company_logo = :cl, updated_at = CURRENT_TIMESTAMP WHERE id = 1'); $params[':cn'] = $company_name;
$stmt->execute([':cl' => $company_logo]); }
} elseif (array_key_exists('company_name', $payload)) { if (array_key_exists('company_logo', $payload)) {
$stmt = $db->prepare('UPDATE settings SET company_name = :cn, updated_at = CURRENT_TIMESTAMP WHERE id = 1'); $sets[] = 'company_logo = :cl';
$stmt->execute([':cn' => $company_name]); $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([ echo json_encode([
'success' => true, 'success' => true,
'message' => $company_logo === '' && !array_key_exists('company_name', $payload) 'message' => $msg,
? 'Logo entreprise réinitialisé.'
: 'Logo et identité entreprise mis à jour sur NexusPBX !',
]); ]);
break; break;
@@ -1689,6 +1909,14 @@ try {
]); ]);
SystemChecker::ensureAsteriskIncludes(); 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'])) { if (!empty($payload['new_admin_password'])) {
$newPass = (string)$payload['new_admin_password']; $newPass = (string)$payload['new_admin_password'];
@@ -1722,12 +1950,38 @@ try {
$ami = AmiClient::fromDatabase($db); $ami = AmiClient::fromDatabase($db);
$reloadInfo = $ami->reloadPjsipAndDialplan(); $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([ echo json_encode([
'success' => true, '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' => [ 'details' => [
'files' => $writeStats, 'files' => $writeStats,
'reload' => $reloadInfo, 'reload' => $reloadInfo,
'cluster_sync' => $clusterSyncMsg,
], ],
]); ]);
break; break;
@@ -1923,6 +2177,7 @@ try {
$rtpChecksums = !empty($payload['rtp_checksums']) ? 1 : 0; $rtpChecksums = !empty($payload['rtp_checksums']) ? 1 : 0;
$rtpStrict = !empty($payload['rtp_strict']) ? 1 : 0; $rtpStrict = !empty($payload['rtp_strict']) ? 1 : 0;
$rtpTimeout = max(5, (int)($payload['rtp_timeout'] ?? 30)); $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)); $rtpKeepalive = max(0, (int)($payload['rtp_keepalive'] ?? 15));
$allowAnonymousSip = !empty($payload['allow_anonymous_sip']) ? 1 : 0; $allowAnonymousSip = !empty($payload['allow_anonymous_sip']) ? 1 : 0;
@@ -1957,6 +2212,7 @@ try {
rtp_checksums = :rtp_checksums, rtp_checksums = :rtp_checksums,
rtp_strict = :rtp_strict, rtp_strict = :rtp_strict,
rtp_timeout = :rtp_timeout, rtp_timeout = :rtp_timeout,
rtp_timeout_hold = :rtp_timeout_hold,
rtp_keepalive = :rtp_keepalive, rtp_keepalive = :rtp_keepalive,
allow_anonymous_sip = :anon, allow_anonymous_sip = :anon,
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
@@ -1976,6 +2232,7 @@ try {
':rtp_checksums' => $rtpChecksums, ':rtp_checksums' => $rtpChecksums,
':rtp_strict' => $rtpStrict, ':rtp_strict' => $rtpStrict,
':rtp_timeout' => $rtpTimeout, ':rtp_timeout' => $rtpTimeout,
':rtp_timeout_hold' => $rtpHoldTimeout,
':rtp_keepalive' => $rtpKeepalive, ':rtp_keepalive' => $rtpKeepalive,
':anon' => $allowAnonymousSip, ':anon' => $allowAnonymousSip,
]); ]);
@@ -2231,25 +2488,103 @@ try {
echo json_encode($res); echo json_encode($res);
break; 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': case 'check_update':
$appDir = dirname(__DIR__); $appDir = dirname(__DIR__);
clearstatcache(true, "{$appDir}/version.json");
$localVerJson = file_exists("{$appDir}/version.json") $localVerJson = file_exists("{$appDir}/version.json")
? json_decode((string)file_get_contents("{$appDir}/version.json"), true) ? 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; $remoteVer = $currentVer;
$remoteChangelog = []; $remoteChangelog = [];
$remoteDate = ''; $remoteDate = '';
// 1. Interrogation directe via HTTP Raw (indépendant de .git et ultra-rapide) // 1. Interrogation directe via HTTP Raw avec cache-busting strict (bypass OpenResty/Cloudflare max-age)
$rawHttp = @shell_exec("curl -k -s --connect-timeout 4 \"https://git.sorcier-research.com/sorcier/NexusPBX/raw/branch/main/version.json\" 2>/dev/null"); $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)) { if (empty($rawHttp)) {
$ctx = stream_context_create([ $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] '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)) { if (!empty($rawHttp)) {
@@ -2274,11 +2609,6 @@ try {
} }
$hasUpdate = version_compare($remoteVer, $currentVer, '>'); $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([ echo json_encode([
'success' => true, 'success' => true,
@@ -2291,22 +2621,73 @@ try {
break; break;
case 'run_update': case 'run_update':
@set_time_limit(300);
$output = []; $output = [];
$code = 0; $code = 0;
exec("sudo /var/www/nexus-pbx/scripts/update.sh 2>&1", $output, $code); exec("sudo /var/www/nexus-pbx/scripts/update.sh 2>&1", $output, $code);
$outputStr = implode("\n", $output); $outputStr = implode("\n", $output);
$appDir = dirname(__DIR__); $appDir = dirname(__DIR__);
clearstatcache(true, "{$appDir}/version.json");
if (function_exists('opcache_reset')) {
@opcache_reset();
}
$newVerJson = file_exists("{$appDir}/version.json") $newVerJson = file_exists("{$appDir}/version.json")
? json_decode((string)file_get_contents("{$appDir}/version.json"), true) ? 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([ echo json_encode([
'success' => ($code === 0), 'success' => ($code === 0),
'new_version' => $newVerJson['version'] ?? '1.3.0', 'new_version' => $newVerJson['version'] ?? '1.4.3',
'output' => $outputStr, 'output' => $outputStr,
'message' => ($code === 0) ? "Mise à jour réussie !" : "Erreur lors de la mise à jour", '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; break;
default: default:
+1559 -102
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 "• 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 "• 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"; 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"; echo "• Total IPs Bannies : " . $st['total_banned'] . "\n";
if (!empty($st['banned_ips'])) { if (!empty($st['banned_ips'])) {
echo "\nListe des IPs bannies :\n"; 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"; echo ($res['success'] ? "\033[32m[OK]\033[0m " : "\033[31m[ERREUR]\033[0m ") . $res['message'] . "\n";
break; 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: 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); 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}" echo -e "\n${C_YELLOW}-> Mise à jour vers la version v${REMOTE_VER}...${C_RESET}"
# 1. Sauvegarde préventive automatique # Nettoyage préventif d'éventuels anciens dossiers temporaires HA dans /tmp
echo -e "${C_GRAY}[1/6] Création d'une sauvegarde préventive du système...${C_RESET}" 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
mkdir -p "$APP_DIR/storage/backups"
BACKUP_DATE=$(date +%Y%m%d_%H%M%S) # 1. Sauvegarde préventive automatique (Disaster Recovery complète)
BACKUP_FILE="$APP_DIR/storage/backups/pre-update-${BACKUP_DATE}.tar.gz" echo -e "${C_GRAY}[1/6] Création d'une sauvegarde préventive du système (Disaster Recovery)...${C_RESET}"
tar -czf "$BACKUP_FILE" \ php -r '
-C "$APP_DIR/storage" database.sqlite \ require_once "/var/www/nexus-pbx/src/Database.php";
-C /etc/asterisk pjsip.custom.conf extensions.custom.conf 2>/dev/null || true require_once "/var/www/nexus-pbx/src/BackupManager.php";
echo -e "${C_GREEN} ✓ Sauvegarde de sécurité créée : $(basename "$BACKUP_FILE")${C_RESET}" 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 # 2. Application des derniers fichiers de code Git
echo -e "${C_GRAY}[2/6] Déploiement des nouveaux fichiers de code...${C_RESET}" 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 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 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) # 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/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-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-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 # Mise à jour des privilèges sudoers (www-data) si accessible en écriture
if [ -w /etc/sudoers.d ]; then if [ -w /etc/sudoers.d ]; then
cat << 'EOF_SUDO' > /etc/sudoers.d/nexus-pbx 2>/dev/null || true 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 EOF_SUDO
chmod 0440 /etc/sudoers.d/nexus-pbx 2>/dev/null || true chmod 0440 /etc/sudoers.d/nexus-pbx 2>/dev/null || true
fi 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}" 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 || { 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 complémentaires..." echo -e " -> Installation de paquets système complémentaires..."
(apt-get update -y -qq && apt-get install -y -qq --no-install-recommends acl ffmpeg rsync sox sshpass) 2>/dev/null || true (apt-get update -y -qq && apt-get install -y -qq --no-install-recommends 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 PHP_MODS=$(php -m 2>/dev/null || true)
echo -e "${C_GRAY}[4/6] Exécution des migrations de base de données & régénération dialplan...${C_RESET}" 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 ' php -r '
require_once "/var/www/nexus-pbx/src/Database.php"; 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/ConfigGenerator.php";
require_once "/var/www/nexus-pbx/src/FirewallManager.php";
$db = App\Database::getConnection(true); $db = App\Database::getConnection(true);
$gen = new App\ConfigGenerator($db); $gen = new App\ConfigGenerator($db);
$gen->writeAllConfigs(); $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 # 5. Fix permissions & ACLs
echo -e "${C_GRAY}[5/6] Application des permissions et ACLs système...${C_RESET}" 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 php -r '
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 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 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 # 6. Rechargement des services
echo -e "${C_GRAY}[6/6] Rechargement à chaud d'Asterisk et des services web...${C_RESET}" 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 "core reload" >/dev/null 2>&1 || true
asterisk -rx "pjsip reload" >/dev/null 2>&1 || true asterisk -rx "pjsip reload" >/dev/null 2>&1 || true
asterisk -rx "dialplan 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 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" NEW_VER="Inconnue"
if [ -f "$APP_DIR/version.json" ]; then if [ -f "$APP_DIR/version.json" ]; then
@@ -157,11 +279,18 @@ echo ""
if [ -f "$APP_DIR/version.json" ]; then if [ -f "$APP_DIR/version.json" ]; then
echo -e "${C_CYAN}${C_BOLD}Nouveautés de cette version :${C_RESET}" echo -e "${C_CYAN}${C_BOLD}Nouveautés de cette version :${C_RESET}"
php -r ' php -r '
$v = json_decode(file_get_contents("/var/www/nexus-pbx/version.json"), true); $v = json_decode(@file_get_contents("/var/www/nexus-pbx/version.json"), true);
if (!empty($v["changelog"])) { if (!empty($v["changelog"]) && is_array($v["changelog"])) {
foreach ($v["changelog"] as $line) { $first = $v["changelog"][0];
if (is_array($first) && !empty($first["changes"])) {
foreach ($first["changes"] as $line) {
echo " • " . $line . "\n"; echo " • " . $line . "\n";
} }
} else {
foreach ($v["changelog"] as $line) {
if (is_string($line)) echo " • " . $line . "\n";
}
}
} }
' '
fi fi
+36 -5
View File
@@ -11,7 +11,22 @@ use InvalidArgumentException;
class BackupManager class BackupManager
{ {
public const BACKUP_DIR = '/var/www/nexus-pbx/storage/system_backups'; 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. * Initialise le répertoire des sauvegardes.
@@ -182,7 +197,8 @@ class BackupManager
} }
$timestamp = date('Ymd_His'); $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; $localArchivePath = self::BACKUP_DIR . '/' . $filename;
// 2. Préparation du dossier de staging // 2. Préparation du dossier de staging
@@ -268,7 +284,7 @@ class BackupManager
// G. Création du Manifeste // G. Création du Manifeste
$manifest = [ $manifest = [
'nexus_version' => self::VERSION, 'nexus_version' => $currentVer,
'created_at' => date('c'), 'created_at' => date('c'),
'timestamp' => time(), 'timestamp' => time(),
'filename' => $filename, 'filename' => $filename,
@@ -360,6 +376,14 @@ class BackupManager
// Tenter de lire le manifest depuis l'archive sans l'extraire entièrement // Tenter de lire le manifest depuis l'archive sans l'extraire entièrement
$manifest = self::readManifestFromTar($file); $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[] = [ $backups[] = [
'filename' => $name, 'filename' => $name,
'storage_type' => 'local', 'storage_type' => 'local',
@@ -367,7 +391,7 @@ class BackupManager
'size_formatted' => self::formatBytes($size), 'size_formatted' => self::formatBytes($size),
'timestamp' => $mtime, 'timestamp' => $mtime,
'date_formatted' => date('d/m/Y H:i:s', $mtime), 'date_formatted' => date('d/m/Y H:i:s', $mtime),
'version' => $manifest['nexus_version'] ?? self::VERSION, 'version' => $archiveVer,
'source' => $manifest['source'] ?? 'local', 'source' => $manifest['source'] ?? 'local',
'is_local' => true, 'is_local' => true,
]; ];
@@ -807,6 +831,13 @@ class BackupManager
$name = basename($fullPath); $name = basename($fullPath);
if (str_ends_with($name, '.tar.gz')) { 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[] = [ $results[] = [
'filename' => $name, 'filename' => $name,
'storage_type' => 'ssh', 'storage_type' => 'ssh',
@@ -814,7 +845,7 @@ class BackupManager
'size_formatted' => self::formatBytes($size), 'size_formatted' => self::formatBytes($size),
'timestamp' => $timestamp > 0 ? $timestamp : time(), 'timestamp' => $timestamp > 0 ? $timestamp : time(),
'date_formatted' => date('d/m/Y H:i:s', $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)', 'source' => 'distant (SSH)',
'is_local' => false, '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' : ''; $stagingFlag = $staging ? '--test-cert' : '';
// Exécution de Certbot webroot non-interactive (Clé RSA 2048 universellement compatible téléphones IP Yealink/Fanvil/Grandstream) // Exécution de Certbot webroot non-interactive avec sudo (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"; $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 = []; $output = [];
$exitCode = 0; $exitCode = 0;
@@ -444,7 +445,8 @@ class CertificateManager
$domain = $cert['primary_domain']; $domain = $cert['primary_domain'];
$escDomain = escapeshellarg($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 = []; $output = [];
$exitCode = 0; $exitCode = 0;
exec($cmd, $output, $exitCode); 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 RTP_CONF_PATH = '/etc/asterisk/rtp.conf';
public const FEATURES_CONF_PATH = '/etc/asterisk/features.conf'; public const FEATURES_CONF_PATH = '/etc/asterisk/features.conf';
public const VOICEMAIL_CONF_PATH = '/etc/asterisk/voicemail.custom.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 const BACKUP_DIR = __DIR__ . '/../storage/backups';
public function __construct(private PDO $db) {} 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[] = "; ==========================================================================="; $lines[] = "; ===========================================================================";
$lines[] = "; FICHIER GÉNÉRÉ AUTOMATIQUEMENT PAR NEXUSPBX ($date)"; $lines[] = "; FICHIER GÉNÉRÉ AUTOMATIQUEMENT PAR NEXUSPBX ($date)";
@@ -67,12 +87,27 @@ class ConfigGenerator
$lines[] = "; ==========================================================================="; $lines[] = "; ===========================================================================";
$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) // 1. Transport UDP (Postes internes LAN & Trunks standards)
$lines[] = "; --- TRANSPORT UDP (LAN / INTERNE) ---"; $lines[] = "; --- TRANSPORT UDP (LAN / INTERNE) ---";
$lines[] = "[transport-udp]"; $lines[] = "[transport-udp]";
$lines[] = "type = transport"; $lines[] = "type = transport";
$lines[] = "protocol = udp"; $lines[] = "protocol = udp";
$lines[] = "bind = 0.0.0.0:{$udpPort}"; $lines[] = "bind = {$sipBindHost}:{$udpPort}";
$lines[] = "symmetric_transport = yes"; $lines[] = "symmetric_transport = yes";
foreach ($localNetworks as $net) { foreach ($localNetworks as $net) {
$net = trim((string)$net); $net = trim((string)$net);
@@ -86,6 +121,9 @@ class ConfigGenerator
if ($extUdpPort > 0) { if ($extUdpPort > 0) {
$lines[] = "external_signaling_port = {$extUdpPort}"; $lines[] = "external_signaling_port = {$extUdpPort}";
} }
} elseif ($haVip !== '') {
$lines[] = "external_media_address = {$haVip}";
$lines[] = "external_signaling_address = {$haVip}";
} }
$lines[] = "allow_reload = no"; $lines[] = "allow_reload = no";
$lines[] = ""; $lines[] = "";
@@ -95,7 +133,7 @@ class ConfigGenerator
$lines[] = "[transport-tcp]"; $lines[] = "[transport-tcp]";
$lines[] = "type = transport"; $lines[] = "type = transport";
$lines[] = "protocol = tcp"; $lines[] = "protocol = tcp";
$lines[] = "bind = 0.0.0.0:{$udpPort}"; $lines[] = "bind = {$sipBindHost}:{$udpPort}";
$lines[] = "symmetric_transport = yes"; $lines[] = "symmetric_transport = yes";
foreach ($localNetworks as $net) { foreach ($localNetworks as $net) {
$net = trim((string)$net); $net = trim((string)$net);
@@ -109,7 +147,11 @@ class ConfigGenerator
if ($extUdpPort > 0) { if ($extUdpPort > 0) {
$lines[] = "external_signaling_port = {$extUdpPort}"; $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[] = "allow_reload = no";
$lines[] = ""; $lines[] = "";
@@ -152,14 +194,19 @@ class ConfigGenerator
$lines[] = "[transport-tls]"; $lines[] = "[transport-tls]";
$lines[] = "type = transport"; $lines[] = "type = transport";
$lines[] = "protocol = tls"; $lines[] = "protocol = tls";
$lines[] = "bind = 0.0.0.0:{$tlsPort}"; $lines[] = "bind = {$sipBindHost}:{$tlsPort}";
$lines[] = "symmetric_transport = yes"; $lines[] = "symmetric_transport = yes";
$lines[] = "cert_file = {$certPath}"; $lines[] = "cert_file = {$certPath}";
$lines[] = "priv_key_file = {$keyPath}"; $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[] = "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_client = {$verifyClient}";
$lines[] = "verify_server = no"; $lines[] = "verify_server = no";
foreach ($localNetworks as $net) { foreach ($localNetworks as $net) {
@@ -174,7 +221,11 @@ class ConfigGenerator
if ($extTlsPort > 0) { if ($extTlsPort > 0) {
$lines[] = "external_signaling_port = {$extTlsPort}"; $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[] = "allow_reload = no";
$lines[] = ""; $lines[] = "";
@@ -182,6 +233,18 @@ class ConfigGenerator
$lines[] = "; ==========================================================================="; $lines[] = "; ===========================================================================";
$lines[] = "; POSTES TÉLÉPHONIQUES (EXTENSIONS)"; $lines[] = "; POSTES TÉLÉPHONIQUES (EXTENSIONS)";
$lines[] = "; ==========================================================================="; $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) { foreach ($extensions as $ext) {
$num = trim((string)$ext['extension']); $num = trim((string)$ext['extension']);
$name = str_replace('"', '', trim((string)$ext['name'])); $name = str_replace('"', '', trim((string)$ext['name']));
@@ -216,6 +279,19 @@ class ConfigGenerator
$lines[] = "force_rport = yes"; $lines[] = "force_rport = yes";
$lines[] = "rewrite_contact = yes"; $lines[] = "rewrite_contact = yes";
$lines[] = "direct_media = no"; $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) { if ($wantsTls && $hasTlsCerts) {
// Verrouillage strict du transport TLS : refuse le repli sur UDP // Verrouillage strict du transport TLS : refuse le repli sur UDP
$lines[] = "transport = transport-tls"; $lines[] = "transport = transport-tls";
@@ -256,6 +332,9 @@ class ConfigGenerator
$lines[] = "type = aor"; $lines[] = "type = aor";
$lines[] = "max_contacts = {$maxContacts}"; $lines[] = "max_contacts = {$maxContacts}";
$lines[] = "remove_existing = yes"; $lines[] = "remove_existing = yes";
$lines[] = "default_expiration = {$regExpiration}";
$lines[] = "maximum_expiration = {$regMaxExpiration}";
$lines[] = "minimum_expiration = " . ($isHaActive ? 30 : 60);
$lines[] = "qualify_frequency = 30"; $lines[] = "qualify_frequency = 30";
if ($hasVm) { if ($hasVm) {
$lines[] = "mailboxes = {$num}@default"; $lines[] = "mailboxes = {$num}@default";
@@ -287,13 +366,14 @@ class ConfigGenerator
} }
$context = trim((string)($trunk['context'] ?? 'from-external')) ?: 'from-external'; $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; $fromDomain = trim((string)($trunk['from_domain'] ?? '')) ?: $host;
$callerId = trim((string)($trunk['outbound_callerid'] ?? '')); $callerId = trim((string)($trunk['outbound_callerid'] ?? ''));
$sendPai = ((int)($trunk['send_pai'] ?? 1) === 1) ? 'yes' : 'no'; $sendPai = ((int)($trunk['send_pai'] ?? 1) === 1) ? 'yes' : 'no';
$sendRpid = ((int)($trunk['send_rpid'] ?? 0) === 1) ? 'yes' : 'no'; $sendRpid = ((int)($trunk['send_rpid'] ?? 0) === 1) ? 'yes' : 'no';
$sendDiversion = ((int)($trunk['send_diversion'] ?? 1) === 1) ? 'yes' : 'no'; $sendDiversion = ((int)($trunk['send_diversion'] ?? 1) === 1) ? 'yes' : 'no';
$trustInbound = ((int)($trunk['trust_id_inbound'] ?? 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'; $dtmfMode = trim((string)($trunk['dtmf_mode'] ?? 'rfc4733')) ?: 'rfc4733';
$mediaEnc = trim((string)($trunk['media_encryption'] ?? 'no')) ?: 'no'; $mediaEnc = trim((string)($trunk['media_encryption'] ?? 'no')) ?: 'no';
$natSym = ((int)($trunk['nat_symmetric'] ?? 1) === 1) ? 'yes' : 'no'; $natSym = ((int)($trunk['nat_symmetric'] ?? 1) === 1) ? 'yes' : 'no';
@@ -345,9 +425,10 @@ class ConfigGenerator
if ($outboundProxy !== '') { if ($outboundProxy !== '') {
$lines[] = "outbound_proxy = {$outboundProxy}"; $lines[] = "outbound_proxy = {$outboundProxy}";
} }
if ($callerId !== '') { // Note : Ne JAMAIS positionner la directive "callerid" dans la section endpoint d'un Trunk PJSIP.
$lines[] = "callerid = {$callerId}"; // 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"; $lines[] = "disallow = all";
foreach ($codecs as $codec) { foreach ($codecs as $codec) {
$lines[] = "allow = {$codec}"; $lines[] = "allow = {$codec}";
@@ -362,7 +443,12 @@ class ConfigGenerator
$lines[] = "send_rpid = {$sendRpid}"; $lines[] = "send_rpid = {$sendRpid}";
$lines[] = "send_diversion = {$sendDiversion}"; $lines[] = "send_diversion = {$sendDiversion}";
$lines[] = "trust_id_inbound = {$trustInbound}"; $lines[] = "trust_id_inbound = {$trustInbound}";
$lines[] = "trust_id_outbound = {$trustOutbound}";
$lines[] = "timers = {$timers}"; $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) { if ($rtpKeepalive > 0) {
$lines[] = "rtp_keepalive = {$rtpKeepalive}"; $lines[] = "rtp_keepalive = {$rtpKeepalive}";
} }
@@ -571,10 +657,6 @@ class ConfigGenerator
$lines[] = " same => n,Hangup()"; $lines[] = " same => n,Hangup()";
} }
if ($groupCode !== '') { 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[] = "exten => {$groupCode},1,NoOp(Interception d'appel de groupe)";
$lines[] = " same => n,Pickup()"; $lines[] = " same => n,Pickup()";
$lines[] = " same => n,Hangup()"; $lines[] = " same => n,Hangup()";
@@ -905,9 +987,9 @@ class ConfigGenerator
$lines[] = " same => n,Hangup()"; $lines[] = " same => n,Hangup()";
} }
$lines[] = " same => n(cf_{$num}),NoOp(Poste {$num} renvoi inconditionnel vers \${DB(CF/{$num})})"; $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(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[] = ""; $lines[] = "";
} }
@@ -986,9 +1068,10 @@ class ConfigGenerator
$lines[] = ""; $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[] = "; --- 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,Set(CHANNEL(language)={$sysLang})";
$lines[] = " same => n,Answer()"; $lines[] = " same => n,Answer()";
$lines[] = " same => n,Playback(beep)"; $lines[] = " same => n,Playback(beep)";
@@ -997,13 +1080,14 @@ class ConfigGenerator
$lines[] = ""; $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)) { 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) { foreach ($ivrs as $ivr) {
$ivrSlug = $this->slugify($ivr['name']); $ivrSlug = $this->slugify($ivr['name']);
$testCode = '*8' . str_pad((string)$ivr['id'], 2, '0', STR_PAD_LEFT); $testCode = '*8' . str_pad((string)$ivr['id'], 2, '0', STR_PAD_LEFT);
$lines[] = "exten => {$testCode},1,NoOp(Test SVI {$ivr['name']})"; $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[] = " same => n,Goto(ivr-{$ivrSlug},s,1)";
} }
$lines[] = ""; $lines[] = "";
@@ -1152,7 +1236,9 @@ class ConfigGenerator
// 3. Positionnement du CallerID Sortant de la route // 3. Positionnement du CallerID Sortant de la route
if ($rCid !== '') { 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) // 4. Séquence de Trunks avec bascule automatique (Failover)
@@ -1166,8 +1252,10 @@ class ConfigGenerator
$tSlug = $tObj ? $this->slugify($tObj['name']) : $this->slugify($tKey); $tSlug = $tObj ? $this->slugify($tObj['name']) : $this->slugify($tKey);
$tFallbackCid = $tObj ? trim((string)($tObj['outbound_callerid'] ?? '')) : ''; $tFallbackCid = $tObj ? trim((string)($tObj['outbound_callerid'] ?? '')) : '';
if ($rCid === '' && $tFallbackCid !== '' && $overrideCid) { if ($rCid === '' && $tFallbackCid !== '') {
$sub[] = " same => n,Set(CALLERID(all)={$tFallbackCid})"; foreach ($this->buildCallerIdDialplan($tFallbackCid) as $cidStmt) {
$sub[] = $cidStmt;
}
} }
$validTrunkCount++; $validTrunkCount++;
@@ -1500,6 +1588,7 @@ class ConfigGenerator
$lines[] = "; --- Serveur Vocal Interactif : {$ivr['name']} ---"; $lines[] = "; --- Serveur Vocal Interactif : {$ivr['name']} ---";
$lines[] = "[ivr-{$ivrSlug}]"; $lines[] = "[ivr-{$ivrSlug}]";
$lines[] = "exten => s,1,NoOp(SVI {$ivr['name']})"; $lines[] = "exten => s,1,NoOp(SVI {$ivr['name']})";
$lines[] = " same => n,Set(CHANNEL(language)={$sysLang})";
$lines[] = " same => n,Answer()"; $lines[] = " same => n,Answer()";
$lines[] = " same => n,Wait(1)"; $lines[] = " same => n,Wait(1)";
$lines[] = " same => n,Set(IVR_INVALID_COUNT=0)"; $lines[] = " same => n,Set(IVR_INVALID_COUNT=0)";
@@ -1597,12 +1686,33 @@ class ConfigGenerator
$lines[] = " same => n,Hangup()"; $lines[] = " same => n,Hangup()";
$lines[] = ""; $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"; 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 * 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'; $checksums = !empty($settings['rtp_checksums']) ? 'yes' : 'no';
$strict = !empty($settings['rtp_strict']) ? 'yes' : 'no'; $strict = !empty($settings['rtp_strict']) ? 'yes' : 'no';
$timeout = max(5, (int)($settings['rtp_timeout'] ?? 30)); $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)); $keepalive = max(0, (int)($settings['rtp_keepalive'] ?? 15));
$date = date('Y-m-d H:i:s'); $date = date('Y-m-d H:i:s');
@@ -1753,7 +1864,7 @@ class ConfigGenerator
$lines[] = "rtpchecksums = {$checksums}"; $lines[] = "rtpchecksums = {$checksums}";
$lines[] = "strictrtp = {$strict}"; $lines[] = "strictrtp = {$strict}";
$lines[] = "rtptimeout = {$timeout}"; $lines[] = "rtptimeout = {$timeout}";
$lines[] = "rtpholdtimeout = 300"; $lines[] = "rtpholdtimeout = {$holdTimeout}";
if ($keepalive > 0) { if ($keepalive > 0) {
$lines[] = "rtpkeepalive = {$keepalive}"; $lines[] = "rtpkeepalive = {$keepalive}";
} }
@@ -1804,6 +1915,10 @@ class ConfigGenerator
$lines[] = "; MESSAGERIE VOCALE (VOICEMAIL) - CONTEXTE [default]"; $lines[] = "; MESSAGERIE VOCALE (VOICEMAIL) - CONTEXTE [default]";
$lines[] = "; ==========================================================================="; $lines[] = "; ===========================================================================";
$lines[] = ""; $lines[] = "";
$lines[] = "[general](+)";
$lines[] = "pollmailboxes = yes";
$lines[] = "pollfreq = 10";
$lines[] = "";
$lines[] = "[default](+)"; $lines[] = "[default](+)";
foreach ($extensions as $ext) { foreach ($extensions as $ext) {
@@ -1846,6 +1961,48 @@ class ConfigGenerator
return implode("\n", $lines) . "\n"; 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 public function writeAllConfigs(): array
{ {
if (!is_dir(self::BACKUP_DIR)) { if (!is_dir(self::BACKUP_DIR)) {
@@ -1858,6 +2015,7 @@ class ConfigGenerator
$rtpContent = $this->generateRtpConf(); $rtpContent = $this->generateRtpConf();
$featuresContent = $this->generateFeaturesConf(); $featuresContent = $this->generateFeaturesConf();
$voicemailContent = $this->generateVoicemailConf(); $voicemailContent = $this->generateVoicemailConf();
$notifyContent = $this->generatePjsipNotifyConf();
$this->writeAtomicWithPermissions(self::PJSIP_CONF_PATH, $pjsipContent); $this->writeAtomicWithPermissions(self::PJSIP_CONF_PATH, $pjsipContent);
$this->writeAtomicWithPermissions(self::EXTENSIONS_CONF_PATH, $extensionsContent); $this->writeAtomicWithPermissions(self::EXTENSIONS_CONF_PATH, $extensionsContent);
@@ -1865,6 +2023,7 @@ class ConfigGenerator
$this->writeAtomicWithPermissions(self::RTP_CONF_PATH, $rtpContent); $this->writeAtomicWithPermissions(self::RTP_CONF_PATH, $rtpContent);
$this->writeAtomicWithPermissions(self::FEATURES_CONF_PATH, $featuresContent); $this->writeAtomicWithPermissions(self::FEATURES_CONF_PATH, $featuresContent);
$this->writeAtomicWithPermissions(self::VOICEMAIL_CONF_PATH, $voicemailContent); $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 // S'assurer que pjsip.conf et extensions.conf incluent bien les fichiers .custom.conf
SystemChecker::ensureAsteriskIncludes(); SystemChecker::ensureAsteriskIncludes();
@@ -2193,6 +2352,17 @@ class ConfigGenerator
if (preg_match('/^\d+$/', $dest)) { if (preg_match('/^\d+$/', $dest)) {
return "Goto(from-internal,{$dest},1)"; 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-')) { if (str_starts_with($dest, 'page-')) {
$pageExt = substr($dest, 5); $pageExt = substr($dest, 5);
return "Goto(from-internal,{$pageExt},1)"; return "Goto(from-internal,{$pageExt},1)";
@@ -2223,18 +2393,24 @@ class ConfigGenerator
$stmt = $this->db->prepare('SELECT slug FROM sounds WHERE id = :id'); $stmt = $this->db->prepare('SELECT slug FROM sounds WHERE id = :id');
$stmt->execute([':id' => (int)$soundRef]); $stmt->execute([':id' => (int)$soundRef]);
$slug = $stmt->fetchColumn(); $slug = $stmt->fetchColumn();
return $slug ? "custom/{$slug}" : $fallback; return $slug ? "/var/lib/asterisk/sounds/custom/{$slug}" : $fallback;
} }
$ref = trim((string)$soundRef); $ref = trim((string)$soundRef);
if (str_starts_with($ref, 'sound-')) { if (str_starts_with($ref, 'sound-')) {
return $this->resolveSoundFile(substr($ref, 6), $fallback); 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'])) { 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 = $this->db->prepare('SELECT slug FROM sounds WHERE slug = :slug OR name = :name');
$stmt->execute([':slug' => $ref, ':name' => $ref]); $stmt->execute([':slug' => $ref, ':name' => $ref]);
$slug = $stmt->fetchColumn(); $slug = $stmt->fetchColumn();
if ($slug) { if ($slug) {
return "custom/{$slug}"; return "/var/lib/asterisk/sounds/custom/{$slug}";
} }
} }
return $ref ?: $fallback; return $ref ?: $fallback;
@@ -2247,5 +2423,60 @@ class ConfigGenerator
$slug = preg_replace('/[^a-z0-9]+/', '-', $slug); $slug = preg_replace('/[^a-z0-9]+/', '-', $slug);
return trim($slug, '-') ?: 'default'; 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); 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, [ self::$instance = new PDO('sqlite:' . self::DB_PATH, null, null, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
@@ -41,10 +41,19 @@ class Database
self::$instance->exec('PRAGMA journal_mode = WAL;'); self::$instance->exec('PRAGMA journal_mode = WAL;');
self::$instance->exec('PRAGMA busy_timeout = 5000;'); self::$instance->exec('PRAGMA busy_timeout = 5000;');
self::initializeSchema();
if ($isNew) { if ($isNew) {
self::initializeSchema();
@chmod(self::DB_PATH, 0660); @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_checksums INTEGER NOT NULL DEFAULT 1,
rtp_strict INTEGER NOT NULL DEFAULT 1, rtp_strict INTEGER NOT NULL DEFAULT 1,
rtp_timeout INTEGER NOT NULL DEFAULT 30, rtp_timeout INTEGER NOT NULL DEFAULT 30,
rtp_timeout_hold INTEGER NOT NULL DEFAULT 0,
rtp_keepalive INTEGER NOT NULL DEFAULT 15, rtp_keepalive INTEGER NOT NULL DEFAULT 15,
allow_anonymous_sip INTEGER NOT NULL DEFAULT 0, allow_anonymous_sip INTEGER NOT NULL DEFAULT 0,
last_applied_at DATETIME DEFAULT NULL, last_applied_at DATETIME DEFAULT NULL,
@@ -108,6 +118,7 @@ class Database
encryption INTEGER NOT NULL DEFAULT 0 CHECK (encryption IN (0, 1, 2)), encryption INTEGER NOT NULL DEFAULT 0 CHECK (encryption IN (0, 1, 2)),
codecs VARCHAR(64) NOT NULL DEFAULT 'alaw,ulaw,opus', codecs VARCHAR(64) NOT NULL DEFAULT 'alaw,ulaw,opus',
max_contacts INTEGER NOT NULL DEFAULT 2, 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)), enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP 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_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)), 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_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', codecs VARCHAR(64) NOT NULL DEFAULT 'alaw,ulaw,g722',
dtmf_mode VARCHAR(16) NOT NULL DEFAULT 'rfc4733', dtmf_mode VARCHAR(16) NOT NULL DEFAULT 'rfc4733',
media_encryption VARCHAR(16) NOT NULL DEFAULT 'no', media_encryption VARCHAR(16) NOT NULL DEFAULT 'no',
@@ -306,6 +318,44 @@ class Database
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_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; SQL;
$db->exec($schema); $db->exec($schema);
@@ -321,6 +371,7 @@ class Database
self::migrateFeatureCodesTable($db); self::migrateFeatureCodesTable($db);
self::migratePhonebookContactsTable($db); self::migratePhonebookContactsTable($db);
self::migratePagingGroupsTable($db); self::migratePagingGroupsTable($db);
self::migrateClusterSettingsTable($db);
// Création du compte admin par défaut (admin / admin123) s'il n'existe aucun utilisateur // 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(); $userCount = (int)$db->query('SELECT COUNT(*) FROM users')->fetchColumn();
@@ -344,6 +395,9 @@ class Database
if (!in_array('company_logo', $existing, true)) { if (!in_array('company_logo', $existing, true)) {
$db->exec("ALTER TABLE settings ADD COLUMN company_logo TEXT NOT NULL DEFAULT ''"); $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)) { if (!in_array('default_moh_sound_id', $existing, true)) {
$db->exec("ALTER TABLE settings ADD COLUMN default_moh_sound_id INTEGER DEFAULT 2"); $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_checksums' => "INTEGER NOT NULL DEFAULT 1",
'rtp_strict' => "INTEGER NOT NULL DEFAULT 1", 'rtp_strict' => "INTEGER NOT NULL DEFAULT 1",
'rtp_timeout' => "INTEGER NOT NULL DEFAULT 30", 'rtp_timeout' => "INTEGER NOT NULL DEFAULT 30",
'rtp_timeout_hold' => "INTEGER NOT NULL DEFAULT 0",
'rtp_keepalive' => "INTEGER NOT NULL DEFAULT 15", 'rtp_keepalive' => "INTEGER NOT NULL DEFAULT 15",
'allow_anonymous_sip' => "INTEGER NOT NULL DEFAULT 0", 'allow_anonymous_sip' => "INTEGER NOT NULL DEFAULT 0",
'system_language' => "VARCHAR(16) NOT NULL DEFAULT 'fr'", 'system_language' => "VARCHAR(16) NOT NULL DEFAULT 'fr'",
@@ -384,12 +439,25 @@ class Database
'fail2ban_web_maxretry' => "INTEGER NOT NULL DEFAULT 3", 'fail2ban_web_maxretry' => "INTEGER NOT NULL DEFAULT 3",
'fail2ban_web_bantime' => "INTEGER NOT NULL DEFAULT 3600", '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'", '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) { foreach ($extraSettings as $colName => $colDef) {
if (!in_array($colName, $existing, true)) { if (!in_array($colName, $existing, true)) {
$db->exec("ALTER TABLE settings ADD COLUMN {$colName} {$colDef}"); $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 private static function migrateExtensionsTable(PDO $db): void
@@ -432,6 +500,7 @@ class Database
'voicemail_attach' => "INTEGER NOT NULL DEFAULT 1", 'voicemail_attach' => "INTEGER NOT NULL DEFAULT 1",
'voicemail_delete' => "INTEGER NOT NULL DEFAULT 0", 'voicemail_delete' => "INTEGER NOT NULL DEFAULT 0",
'ring_time' => "INTEGER NOT NULL DEFAULT 20", 'ring_time' => "INTEGER NOT NULL DEFAULT 20",
'groundwire_enabled' => "INTEGER NOT NULL DEFAULT 0",
]; ];
foreach ($extraExtensionCols as $colName => $colDef) { foreach ($extraExtensionCols as $colName => $colDef) {
if (!in_array($colName, $existing, true)) { if (!in_array($colName, $existing, true)) {
@@ -525,6 +594,7 @@ class Database
'moh_sound_id' => "INTEGER NOT NULL DEFAULT 0", 'moh_sound_id' => "INTEGER NOT NULL DEFAULT 0",
'cid_name_prefix' => "VARCHAR(64) NOT NULL DEFAULT ''", 'cid_name_prefix' => "VARCHAR(64) NOT NULL DEFAULT ''",
'dial_options' => "VARCHAR(32) NOT NULL DEFAULT 'tTR'", '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) { foreach ($extraColumns as $colName => $colDef) {
@@ -747,8 +817,59 @@ class Database
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP 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 JAIL_LOCAL_PATH = '/etc/fail2ban/jail.local';
public const NGINX_CONF_PATH = '/etc/nginx/sites-available/nexus-pbx'; 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 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. * Récupère la configuration actuelle du pare-feu et de Fail2ban depuis SQLite.
@@ -23,7 +26,9 @@ class FirewallManager
$stmt = $db->query("SELECT $stmt = $db->query("SELECT
firewall_enabled, admin_https_port, certbot_strict_port80, firewall_enabled, admin_https_port, certbot_strict_port80,
fail2ban_sip_maxretry, fail2ban_sip_findtime, fail2ban_sip_bantime, 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"); FROM settings WHERE id = 1");
$row = $stmt->fetch(PDO::FETCH_ASSOC); $row = $stmt->fetch(PDO::FETCH_ASSOC);
@@ -38,6 +43,11 @@ class FirewallManager
'fail2ban_web_maxretry' => 3, 'fail2ban_web_maxretry' => 3,
'fail2ban_web_bantime' => 3600, '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', '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_maxretry' => (int)($row['fail2ban_web_maxretry'] ?? 3),
'fail2ban_web_bantime' => (int)($row['fail2ban_web_bantime'] ?? 3600), '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'), '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 // IP actuelle du client (avec résolution reverse proxy si proxy privé/local)
$currentIp = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1'; $currentIp = self::resolveClientIp();
if (str_contains($currentIp, ',')) {
$currentIp = trim(explode(',', $currentIp)[0]);
}
$status = [ $status = [
'is_running' => $isRunning, 'is_running' => $isRunning,
@@ -151,7 +163,7 @@ class FirewallManager
} }
} else { } else {
// Débannir de toutes les prisons connues // 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"; $cmd = "timeout 2 sudo -n /usr/bin/fail2ban-client set " . escapeshellarg($j) . " unbanip " . escapeshellarg($ip) . " 2>&1";
exec($cmd, $out, $code); exec($cmd, $out, $code);
if ($code === 0) { if ($code === 0) {
@@ -200,6 +212,7 @@ class FirewallManager
{ {
$db = Database::getConnection(); $db = Database::getConnection();
$oldSettings = self::getSettings();
$strictPort80 = !empty($data['certbot_strict_port80']) ? 1 : 0; $strictPort80 = !empty($data['certbot_strict_port80']) ? 1 : 0;
$firewallEnabled = isset($data['firewall_enabled']) ? (!empty($data['firewall_enabled']) ? 1 : 0) : 1; $firewallEnabled = isset($data['firewall_enabled']) ? (!empty($data['firewall_enabled']) ? 1 : 0) : 1;
$adminPort = max(1, min(65535, (int)($data['admin_https_port'] ?? 8443))); $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))); $webMaxRetry = max(1, min(20, (int)($data['fail2ban_web_maxretry'] ?? 3)));
$webBanTime = max(60, min(31536000, (int)($data['fail2ban_web_bantime'] ?? 3600))); $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'] ?? '')); $whitelist = trim((string)($data['fail2ban_whitelist_ips'] ?? ''));
$cleanTokens = array_values(array_filter(preg_split('/\s+/', $whitelist))); $cleanTokens = array_values(array_filter(preg_split('/\s+/', $whitelist)));
@@ -233,6 +266,11 @@ class FirewallManager
fail2ban_web_maxretry = :wmr, fail2ban_web_maxretry = :wmr,
fail2ban_web_bantime = :wbt, fail2ban_web_bantime = :wbt,
fail2ban_whitelist_ips = :wl, 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 updated_at = CURRENT_TIMESTAMP
WHERE id = 1"); WHERE id = 1");
@@ -246,9 +284,15 @@ class FirewallManager
':wmr' => $webMaxRetry, ':wmr' => $webMaxRetry,
':wbt' => $webBanTime, ':wbt' => $webBanTime,
':wl' => $finalWhitelist, ':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( self::generateJailLocal(
$finalWhitelist, $finalWhitelist,
$sipMaxRetry, $sipMaxRetry,
@@ -256,7 +300,12 @@ class FirewallManager
$sipBanTime, $sipBanTime,
$webMaxRetry, $webMaxRetry,
$webBanTime, $webBanTime,
$adminPort $adminPort,
$ucpEnabled,
$ucpHttpsPort,
$ucpHttpPort,
$ucpMaxRetry,
$ucpBanTime
); );
// 3. Rechargement / Redémarrage propre de Fail2ban // 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'); exec('timeout 5 sudo -n /usr/bin/systemctl restart fail2ban 2>&1');
} }
// 4. Mise à jour de la configuration Nginx // 4. Mise à jour de la configuration Nginx & ouverture/fermeture dynamique des ports UCP
self::applyNginxConfig($adminPort, $strictPort80); 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'); @unlink('/tmp/nexus_fw_status_cache.json');
return [ return [
'success' => true, '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(), 'settings' => self::getSettings(),
]; ];
} }
@@ -287,9 +341,30 @@ class FirewallManager
int $sipBanTime, int $sipBanTime,
int $webMaxRetry, int $webMaxRetry,
int $webBanTime, int $webBanTime,
int $adminPort int $adminPort,
?int $ucpEnabled = null,
?int $ucpHttpsPort = null,
?int $ucpHttpPort = null,
?int $ucpMaxRetry = null,
?int $ucpBanTime = null
): void { ): 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}"; $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 $conf = <<<EOF
# Fichier généré automatiquement par NexusPBX # Fichier généré automatiquement par NexusPBX
@@ -331,6 +406,16 @@ bantime = {$webBanTime}
port = {$portsWeb} port = {$portsWeb}
protocol = tcp 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; EOF;
file_put_contents(self::JAIL_LOCAL_PATH, $conf); 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'; $phpSock = '/run/php/php8.4-fpm.sock';
if (!file_exists($phpSock)) { if (!file_exists($phpSock)) {
$found = glob('/run/php/php*-fpm.sock'); $found = glob('/run/php/php*-fpm.sock');
@@ -485,7 +582,19 @@ server {
} }
EOF; 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); file_put_contents(self::NGINX_CONF_PATH, $fullNginxConfig);
@chgrp(self::NGINX_CONF_PATH, 'asterisk'); @chgrp(self::NGINX_CONF_PATH, 'asterisk');
@chmod(self::NGINX_CONF_PATH, 0664); @chmod(self::NGINX_CONF_PATH, 0664);
@@ -500,6 +609,203 @@ EOF;
exec('sudo -n /usr/bin/systemctl reload nginx 2>&1'); 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 private static function getJailDetails(string $jail): array
{ {
exec('timeout 2 sudo -n /usr/bin/fail2ban-client status ' . escapeshellarg($jail) . ' 2>&1', $out); exec('timeout 2 sudo -n /usr/bin/fail2ban-client status ' . escapeshellarg($jail) . ' 2>&1', $out);
@@ -526,7 +832,12 @@ EOF;
$totalFailed = max($totalFailed, $logIncidents); $totalFailed = max($totalFailed, $logIncidents);
} elseif ($jail === 'nexus-web') { } elseif ($jail === 'nexus-web') {
$logIncidents = is_readable('/var/log/nexus_security.log') $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; : 0;
$totalFailed = max($totalFailed, $logIncidents); $totalFailed = max($totalFailed, $logIncidents);
} }
@@ -558,6 +869,7 @@ EOF;
return match ($jail) { return match ($jail) {
'asterisk' => 'Téléphonie SIP (PJSIP)', 'asterisk' => 'Téléphonie SIP (PJSIP)',
'nexus-web' => 'Interface Web Admin', 'nexus-web' => 'Interface Web Admin',
'nexus-ucp' => 'Portail Utilisateur (UCP)',
'sshd' => 'Accès Console SSH', 'sshd' => 'Accès Console SSH',
default => ucfirst($jail), default => ucfirst($jail),
}; };
+53 -26
View File
@@ -48,14 +48,22 @@ class PhonebookManager
CREATE INDEX IF NOT EXISTS idx_phonebook_group ON phonebook_contacts(group_name); CREATE INDEX IF NOT EXISTS idx_phonebook_group ON phonebook_contacts(group_name);
"); ");
// 1. Récupère la cartographie des groupes d'extensions // 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")->fetchAll(); $egRows = $db->query("SELECT id, name, members FROM extension_groups ORDER BY name COLLATE NOCASE ASC")->fetchAll();
$extToGroup = []; $extToGroups = [];
foreach ($egRows as $eg) { foreach ($egRows as $eg) {
$grpName = trim((string)($eg['name'] ?? ''));
if ($grpName === '') {
continue;
}
$members = json_decode((string)$eg['members'], true) ?: []; $members = json_decode((string)$eg['members'], true) ?: [];
foreach ($members as $extNum) { foreach ($members as $extNum) {
if (!isset($extToGroup[(string)$extNum])) { $k = (string)$extNum;
$extToGroup[(string)$extNum] = trim((string)$eg['name']); if (!isset($extToGroups[$k])) {
$extToGroups[$k] = [];
}
if (!in_array($grpName, $extToGroups[$k], true)) {
$extToGroups[$k][] = $grpName;
} }
} }
} }
@@ -80,19 +88,21 @@ class PhonebookManager
$extNum = trim((string)$ext['extension']); $extNum = trim((string)$ext['extension']);
$extName = trim((string)$ext['name']); $extName = trim((string)$ext['name']);
$email = trim((string)($ext['voicemail_email'] ?? '')); $email = trim((string)($ext['voicemail_email'] ?? ''));
$group = $extToGroup[$extNum] ?? 'Général'; $group = !empty($extToGroups[$extNum]) ? implode(', ', $extToGroups[$extNum]) : 'Général';
$activeExtIds[] = $extId; $activeExtIds[] = $extId;
$findStmt->execute([':ext_id' => $extId, ':num' => $extNum]); $findStmt->execute([':ext_id' => $extId, ':num' => $extNum]);
$existing = $findStmt->fetch(PDO::FETCH_ASSOC); $existing = $findStmt->fetch(PDO::FETCH_ASSOC);
if ($existing) { if ($existing) {
$existingEmail = trim((string)($existing['email'] ?? ''));
$finalEmail = $existingEmail !== '' ? $existingEmail : $email;
$updateStmt->execute([ $updateStmt->execute([
':id' => (int)$existing['id'], ':id' => (int)$existing['id'],
':ext_id' => $extId, ':ext_id' => $extId,
':name' => $extName, ':name' => $extName,
':num' => $extNum, ':num' => $extNum,
':email' => $email, ':email' => $finalEmail,
':grp' => $group, ':grp' => $group,
]); ]);
} else { } else {
@@ -122,33 +132,48 @@ class PhonebookManager
{ {
self::syncExtensions($db); self::syncExtensions($db);
$params = []; $stmt = $db->query("SELECT * FROM phonebook_contacts ORDER BY display_name COLLATE NOCASE ASC");
$sql = "SELECT * FROM phonebook_contacts"; $contacts = $stmt ? $stmt->fetchAll(PDO::FETCH_ASSOC) : [];
if ($group !== null && trim($group) !== '' && strtolower(trim($group)) !== 'all' && strtolower(trim($group)) !== 'tous') { $cleanGroup = ($group !== null) ? trim($group) : '';
$cleanGroup = trim($group); $filterByGroup = ($cleanGroup !== '' && !in_array(mb_strtolower($cleanGroup), ['all', 'tous'], true));
$sql .= " WHERE LOWER(group_name) = LOWER(:grp) OR LOWER(group_name) LIKE LOWER(:grp_like) OR LOWER(group_name) IN ('tous', 'all')"; $targetLower = mb_strtolower($cleanGroup);
$params[':grp'] = $cleanGroup;
$params[':grp_like'] = "%{$cleanGroup}%";
}
$sql .= " ORDER BY display_name COLLATE NOCASE ASC"; $result = [];
$stmt = $db->prepare($sql); foreach ($contacts as $c) {
$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) {
if (!empty($c['photo_url'])) { if (!empty($c['photo_url'])) {
$filename = basename((string)$c['photo_url']); $filename = basename((string)$c['photo_url']);
if (!file_exists(self::AVATARS_DIR . '/' . $filename)) { if (!file_exists(self::AVATARS_DIR . '/' . $filename)) {
$c['photo_url'] = ''; $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 +188,12 @@ class PhonebookManager
if ($g !== '') $groups[$g] = true; if ($g !== '') $groups[$g] = true;
} }
$pbRows = $db->query("SELECT DISTINCT group_name FROM phonebook_contacts WHERE group_name != ''")->fetchAll(PDO::FETCH_COLUMN); $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); $g = trim((string)$g);
if ($g !== '') $groups[$g] = true; if ($g !== '') $groups[$g] = true;
} }
}
// Ajoute Issy-Les-Moulineaux si pas encore présent // Ajoute Issy-Les-Moulineaux si pas encore présent
if (!isset($groups['Issy-Les-Moulineaux'])) { 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', : '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') ?: ''; $pjsipMain = @file_get_contents('/etc/asterisk/pjsip.conf') ?: '';
$extMain = @file_get_contents('/etc/asterisk/extensions.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[] = [ $checks[] = [
'id' => 'asterisk_includes', 'id' => 'asterisk_includes',
'label' => 'Inclusions natives (#tryinclude *.custom.conf)', 'label' => 'Inclusions Asterisk & Voicemail (#tryinclude & modules.conf)',
'ok' => $incOk, 'ok' => $incOk,
'detail' => $incOk 'detail' => $incOk
? 'pjsip.conf et extensions.conf incluent bien les fichiers .custom.conf' ? 'pjsip.conf, extensions.conf, voicemail.conf et modules.conf configurés'
: 'Directive #tryinclude manquante dans pjsip.conf ou extensions.conf', : 'Directive #tryinclude manquante ou conflit modules.conf détecté',
]; ];
// 6. Certificats TLS (Let's Encrypt ou Certificat local Debian) // 6. Certificats TLS (Let's Encrypt ou Certificat local Debian)
@@ -142,6 +147,25 @@ class SystemChecker
public static function ensureAsteriskIncludes(): void 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'; $pjsipConf = '/etc/asterisk/pjsip.conf';
if (is_file($pjsipConf) && is_writable($pjsipConf)) { if (is_file($pjsipConf) && is_writable($pjsipConf)) {
$content = (string)file_get_contents($pjsipConf); $content = (string)file_get_contents($pjsipConf);
@@ -177,8 +201,20 @@ class SystemChecker
if (is_file($vmConf) && is_writable($vmConf)) { if (is_file($vmConf) && is_writable($vmConf)) {
$content = (string)file_get_contents($vmConf); $content = (string)file_get_contents($vmConf);
$cleaned = preg_replace('/^1234 => 4242.*/m', ';1234 => 4242,Example Mailbox,root@localhost', $content); $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); $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); $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')) { if (!str_contains($cleaned, 'voicemail.custom.conf')) {
$cleaned = rtrim($cleaned) . "\n\n#tryinclude \"voicemail.custom.conf\"\n"; $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'; $astConf = '/etc/asterisk/asterisk.conf';
if (is_file($astConf) && is_writable($astConf)) { if (is_file($astConf) && is_writable($astConf)) {
$content = (string)file_get_contents($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) { foreach (preg_split('/\r?\n/', $rawContacts) ?: [] as $line) {
// Contact: 101/sip:101@10.10.167.162:5060 5dd9a21be8 Avail 9.234 // Supporte sip:, sips:, sans préfixe, tous types de hash et RTT (numérique, nan, N/A ou absent)
// 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\/]+)\/(?:sips?:)?([^\s]+)\s+([a-zA-Z0-9]+)\s+(\S+)(?:\s+(\S+))?/i', $line, $m)) {
if (preg_match('/Contact:\s+([^\s\/]+)\/sip:([^\s]+)\s+([a-f0-9]+)\s+(\S+)\s+(-?[0-9.]+|nan|-nan)/i', $line, $m)) {
$extNum = $m[1]; $extNum = $m[1];
$uri = $m[2]; $uri = $m[2];
$status = $m[4]; $status = $m[4];
$rttRaw = $m[5] ?? null;
$isAvail = (stripos($status, 'Avail') !== false && stripos($status, 'Unavail') === false) || stripos($status, 'NonQual') !== false; $isAvail = (stripos($status, 'Avail') !== false && stripos($status, 'Unavail') === false) || stripos($status, 'NonQual') !== false;
if ($isAvail) { if ($isAvail) {
$onlineCount++; $onlineCount++;
} }
$seenExts[$extNum] = true;
$meta = $contactMeta[$extNum] ?? []; $meta = $contactMeta[$extNum] ?? [];
$fullUri = $meta['uri'] ?? ("sip:" . $uri); $fullUri = $meta['uri'] ?? ("sip:" . $uri);
@@ -330,7 +332,37 @@ class SystemMetrics
'user_agent' => $userAgent, 'user_agent' => $userAgent,
'active_transport' => $activeTransport, 'active_transport' => $activeTransport,
'status' => $status, '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>
+25 -7
View File
@@ -1,12 +1,30 @@
{ {
"version": "1.3.7", "version": "2.7.3",
"codename": "Helios", "codename": "Pulsar UCP",
"release_date": "2026-11-04", "release_date": "2026-11-12",
"min_php_version": "8.2", "min_php_version": "8.2",
"changelog": [ "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", "Correctif v2.7.3 : conservation de l'adresse email renseignée sur un contact interne de l'Annuaire lors de la synchronisation des postes",
"Bouton 'Vérifier les mises à jour' forçant le rafraîchissement immédiat du cache navigateur (bypass 24h)", "Correctif v2.7.2 : formatage uniforme du changelog dans version.json (suppression de [object Object] dans la fiche de mise à jour)",
"Nouvelle modale de confirmation de restauration système alignée sur le thème sombre NexusPBX", "Correctif v2.7.1 : suppression de l'erreur Nginx 502 transitoire après mise à jour 1-Clic (pause du polling métriques, sonde multi-essais avant rechargement)",
"Remplacement des popups d'alerte natives du navigateur lors des restaurations locales et téléversées" "Nouveauté v2.7.0 : logo spécifique optionnel pour le portail UCP (thème clair), avec repli automatique sur le logo principal puis NexusPBX",
"UI v2.6.7 : remplacement de l'ensemble des icônes emoji par des icônes vectorielles SVG uniformes sur Admin & UCP (sans modifier le menu latéral Admin)",
"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)"
] ]
} }