Compare commits
42
Commits
v1.6.6
..
8efeec66ca
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8efeec66ca | ||
|
|
7922e338e3 | ||
|
|
d229aaf816 | ||
|
|
3ef7ed0010 | ||
|
|
c79faadd99 | ||
|
|
4bd0f8b095 | ||
|
|
d1d42f75b5 | ||
|
|
54fc495fa7 | ||
|
|
2ed38846ae | ||
|
|
e54de57105 | ||
|
|
fd15f05b7a | ||
|
|
040a4d646f | ||
|
|
7fd75fbff4 | ||
|
|
1908af5d13 | ||
|
|
fa8af27e46 | ||
|
|
c1805c9076 | ||
|
|
767fea46b7 | ||
|
|
f14185d597 | ||
|
|
c1c6eab03c | ||
|
|
1d2d6b1911 | ||
|
|
2db19ed2dd | ||
|
|
2db63d66ce | ||
|
|
d2e3be3356 | ||
|
|
c93b52d99c | ||
|
|
0b654c885c | ||
|
|
0b740da87e | ||
|
|
a537fc6469 | ||
|
|
062495a162 | ||
|
|
c9ce4d7c74 | ||
|
|
3f29ac6d2e | ||
|
|
4c49cf8e1c | ||
|
|
3edcabff7c | ||
|
|
940ff800ad | ||
|
|
b76490a4dc | ||
|
|
da0805fd5f | ||
|
|
93f842a363 | ||
|
|
521f21dc9c | ||
|
|
bee2055b62 | ||
|
|
45f546604e | ||
|
|
74e29c690a | ||
|
|
231bef8330 | ||
|
|
18368027ee |
No files matched your search
@@ -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
|
||||||
|
|
||||||
+52
-7
@@ -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
|
||||||
@@ -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
|
||||||
@@ -161,6 +173,12 @@ 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 /var/log/asterisk/cdr-csv
|
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 /var/log/asterisk/cdr-csv/Master.csv
|
touch /var/log/asterisk/messages /var/log/asterisk/security.log /var/log/asterisk/cdr-csv/Master.csv
|
||||||
chown -R asterisk:asterisk /var/log/asterisk 2>/dev/null || true
|
chown -R asterisk:asterisk /var/log/asterisk 2>/dev/null || true
|
||||||
@@ -189,7 +207,7 @@ setfacl -R -d -m u:www-data:rwx,g:asterisk:rwx /etc/asterisk /var/lib/asterisk/s
|
|||||||
# 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, /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,7 +219,7 @@ 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 /etc/letsencrypt /var/lib/letsencrypt
|
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
|
||||||
@@ -470,11 +488,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
|
||||||
@@ -489,6 +524,16 @@ chmod +x /etc/profile.d/nexus-motd.sh
|
|||||||
echo -e "${C_GREEN}[8/8] Démarrage et activation des services système...${C_RESET}"
|
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")
|
||||||
|
|||||||
+207
-7
@@ -12,6 +12,17 @@ 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/CdrManager.php';
|
||||||
|
require_once __DIR__ . '/../src/ClusterManager.php';
|
||||||
|
|
||||||
|
// Autoloader PSR-4 automatique pour toutes les classes du namespace App
|
||||||
|
spl_autoload_register(function (string $class): void {
|
||||||
|
if (str_starts_with($class, 'App\\')) {
|
||||||
|
$file = __DIR__ . '/../src/' . str_replace('\\', '/', substr($class, 4)) . '.php';
|
||||||
|
if (file_exists($file)) {
|
||||||
|
require_once $file;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
use App\Database;
|
use App\Database;
|
||||||
use App\Auth;
|
use App\Auth;
|
||||||
@@ -24,13 +35,13 @@ use App\BackupManager;
|
|||||||
use App\FirewallManager;
|
use App\FirewallManager;
|
||||||
use App\PhonebookManager;
|
use App\PhonebookManager;
|
||||||
use App\CdrManager;
|
use App\CdrManager;
|
||||||
|
use App\ClusterManager;
|
||||||
|
|
||||||
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');
|
||||||
@@ -39,6 +50,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'] ?? ''));
|
||||||
@@ -74,6 +118,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);
|
||||||
@@ -403,6 +448,87 @@ try {
|
|||||||
]);
|
]);
|
||||||
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'] ?? ''));
|
||||||
@@ -418,6 +544,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('
|
||||||
@@ -1738,6 +1872,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'];
|
||||||
@@ -1771,12 +1913,33 @@ try {
|
|||||||
$ami = AmiClient::fromDatabase($db);
|
$ami = AmiClient::fromDatabase($db);
|
||||||
$reloadInfo = $ami->reloadPjsipAndDialplan();
|
$reloadInfo = $ami->reloadPjsipAndDialplan();
|
||||||
|
|
||||||
|
// Actualisation de l'horodatage de modification en base pour la réconciliation cluster
|
||||||
|
try {
|
||||||
|
$db->exec("UPDATE cluster_settings SET updated_at = CURRENT_TIMESTAMP WHERE id = 1");
|
||||||
|
} catch (\Throwable) {}
|
||||||
|
|
||||||
|
// Synchronisation automatique temps réel vers le nœud pair si Cluster actif et Master
|
||||||
|
$clusterSyncMsg = null;
|
||||||
|
try {
|
||||||
|
$cm = new ClusterManager($db);
|
||||||
|
$cSet = $cm->getSettings();
|
||||||
|
$isVipActive = $cm->isVirtualIpActive($cSet['virtual_ip'] ?? '');
|
||||||
|
if (!empty($cSet['enabled']) && !empty($cSet['auto_sync']) && $isVipActive && !empty($cSet['peer_ip'])) {
|
||||||
|
$syncRes = $cm->pushSyncToPeer();
|
||||||
|
$clusterSyncMsg = $syncRes['message'] ?? 'Réplication pair OK';
|
||||||
|
}
|
||||||
|
} catch (\Throwable $ce) {
|
||||||
|
error_log("[CLUSTER] Auto-sync on apply_config error: " . $ce->getMessage());
|
||||||
|
$clusterSyncMsg = "Alerte réplication pair : " . $ce->getMessage();
|
||||||
|
}
|
||||||
|
|
||||||
echo json_encode([
|
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;
|
||||||
@@ -1972,6 +2135,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;
|
||||||
|
|
||||||
@@ -2006,6 +2170,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
|
||||||
@@ -2023,9 +2188,10 @@ try {
|
|||||||
':rtp_start' => $rtpStart,
|
':rtp_start' => $rtpStart,
|
||||||
':rtp_end' => $rtpEnd,
|
':rtp_end' => $rtpEnd,
|
||||||
':rtp_checksums' => $rtpChecksums,
|
':rtp_checksums' => $rtpChecksums,
|
||||||
':rtp_strict' => $rtpStrict,
|
':rtp_strict' => $rtpStrict,
|
||||||
':rtp_timeout' => $rtpTimeout,
|
':rtp_timeout' => $rtpTimeout,
|
||||||
':rtp_keepalive' => $rtpKeepalive,
|
':rtp_timeout_hold' => $rtpHoldTimeout,
|
||||||
|
':rtp_keepalive' => $rtpKeepalive,
|
||||||
':anon' => $allowAnonymousSip,
|
':anon' => $allowAnonymousSip,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -2414,6 +2580,40 @@ try {
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'cluster_status':
|
||||||
|
$cm = new ClusterManager($db);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'status' => $cm->getClusterStatus(),
|
||||||
|
]);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'cluster_interfaces':
|
||||||
|
$cm = new ClusterManager($db);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'interfaces' => $cm->detectNetworkInterfaces(),
|
||||||
|
]);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'cluster_save':
|
||||||
|
$cm = new ClusterManager($db);
|
||||||
|
$res = $cm->saveSettings($payload);
|
||||||
|
echo json_encode($res);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'cluster_sync_now':
|
||||||
|
$cm = new ClusterManager($db);
|
||||||
|
$res = $cm->pushSyncToPeer();
|
||||||
|
echo json_encode($res);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'cluster_switchover':
|
||||||
|
$cm = new ClusterManager($db);
|
||||||
|
$res = $cm->triggerSwitchover();
|
||||||
|
echo json_encode($res);
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
echo json_encode([
|
echo json_encode([
|
||||||
|
|||||||
+487
-6
@@ -951,9 +951,10 @@ function renderExtensionsTable() {
|
|||||||
<td class="py-3.5 px-4">
|
<td class="py-3.5 px-4">
|
||||||
<code class="px-2 py-0.5 rounded bg-anthracite-950 border border-anthracite-700 text-xs font-mono text-slate-300">${escapeHtml(ext.secret)}</code>
|
<code class="px-2 py-0.5 rounded bg-anthracite-950 border border-anthracite-700 text-xs font-mono text-slate-300">${escapeHtml(ext.secret)}</code>
|
||||||
</td>
|
</td>
|
||||||
<td class="py-3.5 px-4 text-right space-x-2">
|
<td class="py-3.5 px-4 text-right space-x-2 whitespace-nowrap">
|
||||||
<button type="button" onclick="editExtension(${Number(ext.id)})" class="text-xs font-semibold text-indigo-300 hover:text-indigo-200">Modifier</button>
|
<button type="button" onclick="promptRebootExtension(${Number(ext.id)})" class="text-xs font-semibold text-amber-400 hover:text-amber-300 transition">Redémarrer</button>
|
||||||
<button type="button" onclick="deleteExtension(${Number(ext.id)})" class="text-xs font-semibold text-red-400 hover:text-red-300">Supprimer</button>
|
<button type="button" onclick="editExtension(${Number(ext.id)})" class="text-xs font-semibold text-indigo-300 hover:text-indigo-200 transition">Modifier</button>
|
||||||
|
<button type="button" onclick="deleteExtension(${Number(ext.id)})" class="text-xs font-semibold text-red-400 hover:text-red-300 transition">Supprimer</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
`;
|
`;
|
||||||
@@ -1109,6 +1110,90 @@ async function deleteExtension(id, optNum = null) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let pendingRebootExtension = null;
|
||||||
|
|
||||||
|
async function promptRebootExtension(id) {
|
||||||
|
const ext = (appState.extensions || []).find(e => Number(e.id) === Number(id));
|
||||||
|
if (!ext) return;
|
||||||
|
|
||||||
|
pendingRebootExtension = ext;
|
||||||
|
|
||||||
|
document.getElementById('reboot-modal-ext-num').textContent = ext.extension;
|
||||||
|
document.getElementById('reboot-modal-ext-name').textContent = ext.name || '';
|
||||||
|
|
||||||
|
const checkingEl = document.getElementById('reboot-modal-checking');
|
||||||
|
const warningEl = document.getElementById('reboot-modal-call-warning');
|
||||||
|
const normalEl = document.getElementById('reboot-modal-normal-info');
|
||||||
|
const btnAction = document.getElementById('btn-confirm-reboot-action');
|
||||||
|
const btnLabel = document.getElementById('reboot-confirm-btn-label');
|
||||||
|
|
||||||
|
checkingEl.classList.remove('hidden');
|
||||||
|
warningEl.classList.add('hidden');
|
||||||
|
normalEl.classList.add('hidden');
|
||||||
|
|
||||||
|
btnAction.disabled = true;
|
||||||
|
btnAction.className = 'px-5 py-2.5 rounded-xl text-xs font-bold bg-anthracite-700 text-slate-400 transition cursor-not-allowed flex items-center gap-2';
|
||||||
|
btnLabel.textContent = 'Vérification en cours...';
|
||||||
|
|
||||||
|
openModal('modal-confirm-reboot-extension');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await apiCall('check_extension_call_status', { extension: ext.extension });
|
||||||
|
checkingEl.classList.add('hidden');
|
||||||
|
btnAction.disabled = false;
|
||||||
|
|
||||||
|
if (res.in_call) {
|
||||||
|
warningEl.classList.remove('hidden');
|
||||||
|
normalEl.classList.add('hidden');
|
||||||
|
|
||||||
|
const detailsEl = document.getElementById('reboot-modal-call-details');
|
||||||
|
if (detailsEl) {
|
||||||
|
detailsEl.textContent = res.call_details || 'Communication active détectée sur ce combiné.';
|
||||||
|
}
|
||||||
|
|
||||||
|
btnAction.className = 'px-5 py-2.5 rounded-xl text-xs font-bold bg-rose-600 hover:bg-rose-500 text-white shadow-lg shadow-rose-600/30 transition flex items-center gap-2';
|
||||||
|
btnLabel.textContent = "Forcer le redémarrage (Couper l'appel)";
|
||||||
|
} else {
|
||||||
|
warningEl.classList.add('hidden');
|
||||||
|
normalEl.classList.remove('hidden');
|
||||||
|
|
||||||
|
btnAction.className = 'px-5 py-2.5 rounded-xl text-xs font-bold bg-amber-600 hover:bg-amber-500 text-white shadow-lg shadow-amber-600/30 transition flex items-center gap-2';
|
||||||
|
btnLabel.textContent = 'Confirmer le redémarrage';
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
checkingEl.classList.add('hidden');
|
||||||
|
normalEl.classList.remove('hidden');
|
||||||
|
btnAction.disabled = false;
|
||||||
|
btnAction.className = 'px-5 py-2.5 rounded-xl text-xs font-bold bg-amber-600 hover:bg-amber-500 text-white shadow-lg shadow-amber-600/30 transition flex items-center gap-2';
|
||||||
|
btnLabel.textContent = 'Confirmer le redémarrage';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeRebootModal() {
|
||||||
|
pendingRebootExtension = null;
|
||||||
|
closeModal('modal-confirm-reboot-extension');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmRebootExtension() {
|
||||||
|
if (!pendingRebootExtension) return;
|
||||||
|
const ext = pendingRebootExtension;
|
||||||
|
const btnAction = document.getElementById('btn-confirm-reboot-action');
|
||||||
|
const btnLabel = document.getElementById('reboot-confirm-btn-label');
|
||||||
|
|
||||||
|
if (btnAction) btnAction.disabled = true;
|
||||||
|
if (btnLabel) btnLabel.textContent = 'Envoi de l\'ordre...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await apiCall('reboot_extension', { extension: ext.extension });
|
||||||
|
closeRebootModal();
|
||||||
|
showToast(res.message, 'success');
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message, 'error');
|
||||||
|
} finally {
|
||||||
|
if (btnAction) btnAction.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function syncEncryptionToggle(transport, currentVal = null) {
|
function syncEncryptionToggle(transport, currentVal = null) {
|
||||||
const sel = document.getElementById('ext-encryption');
|
const sel = document.getElementById('ext-encryption');
|
||||||
if (!sel) return;
|
if (!sel) return;
|
||||||
@@ -1149,6 +1234,9 @@ function renderGroupsTable() {
|
|||||||
const badges = members.length
|
const badges = members.length
|
||||||
? members.map(m => `<span class="px-2 py-0.5 rounded bg-indigo-500/20 text-indigo-200 border border-indigo-500/30 font-mono text-xs font-semibold">${escapeHtml(m)}</span>`).join(' ')
|
? members.map(m => `<span class="px-2 py-0.5 rounded bg-indigo-500/20 text-indigo-200 border border-indigo-500/30 font-mono text-xs font-semibold">${escapeHtml(m)}</span>`).join(' ')
|
||||||
: '<span class="text-slate-500 text-xs italic">Aucun membre</span>';
|
: '<span class="text-slate-500 text-xs italic">Aucun membre</span>';
|
||||||
|
const failoverLabel = String(grp.failover_dest || '').startsWith('vm-')
|
||||||
|
? `<span class="px-2 py-0.5 rounded bg-violet-500/20 text-violet-200 border border-violet-500/30 font-mono text-xs font-semibold">📬 Répondeur ${escapeHtml(String(grp.failover_dest).slice(3))}</span>`
|
||||||
|
: `<code class="text-slate-300">${escapeHtml(grp.failover_dest)}</code>`;
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<tr class="hover:bg-anthracite-750/60 transition">
|
<tr class="hover:bg-anthracite-750/60 transition">
|
||||||
@@ -1156,7 +1244,7 @@ function renderGroupsTable() {
|
|||||||
<td class="py-3.5 px-4 font-bold text-white">${escapeHtml(grp.name)}</td>
|
<td class="py-3.5 px-4 font-bold text-white">${escapeHtml(grp.name)}</td>
|
||||||
<td class="py-3.5 px-4"><code class="text-xs text-cyan-300 font-mono">${escapeHtml(grp.strategy)}</code></td>
|
<td class="py-3.5 px-4"><code class="text-xs text-cyan-300 font-mono">${escapeHtml(grp.strategy)}</code></td>
|
||||||
<td class="py-3.5 px-4"><div class="flex flex-wrap gap-1">${badges}</div></td>
|
<td class="py-3.5 px-4"><div class="flex flex-wrap gap-1">${badges}</div></td>
|
||||||
<td class="py-3.5 px-4 text-xs text-slate-200">${Number(grp.ring_time)}s → <code class="text-slate-300">${escapeHtml(grp.failover_dest)}</code></td>
|
<td class="py-3.5 px-4 text-xs text-slate-200">${Number(grp.ring_time)}s → ${failoverLabel}</td>
|
||||||
<td class="py-3.5 px-4 text-right space-x-2">
|
<td class="py-3.5 px-4 text-right space-x-2">
|
||||||
<button type="button" onclick="editGroup(${Number(grp.id)})" class="text-xs font-semibold text-indigo-300 hover:text-indigo-200">Modifier</button>
|
<button type="button" onclick="editGroup(${Number(grp.id)})" class="text-xs font-semibold text-indigo-300 hover:text-indigo-200">Modifier</button>
|
||||||
<button type="button" onclick="deleteGroup(${Number(grp.id)})" class="text-xs font-semibold text-red-400 hover:text-red-300">Supprimer</button>
|
<button type="button" onclick="deleteGroup(${Number(grp.id)})" class="text-xs font-semibold text-red-400 hover:text-red-300">Supprimer</button>
|
||||||
@@ -1808,6 +1896,21 @@ function buildDestinationOptionsHtml(selectedValue = 'ext-101') {
|
|||||||
html += `</optgroup>`;
|
html += `</optgroup>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const vmExts = exts.filter(e => Number(e.voicemail_enabled) === 1);
|
||||||
|
const hasMissingVm = normVal.startsWith('vm-') && !vmExts.some(e => `vm-${e.extension}` === normVal);
|
||||||
|
if (vmExts.length > 0 || hasMissingVm) {
|
||||||
|
html += `<optgroup label="📬 Messagerie Vocale Directe (Répondeur)">`;
|
||||||
|
if (hasMissingVm) {
|
||||||
|
const missingExt = normVal.slice(3);
|
||||||
|
html += `<option value="${escapeHtml(normVal)}" selected>Répondeur: ${escapeHtml(missingExt)} (messagerie désactivée)</option>`;
|
||||||
|
}
|
||||||
|
vmExts.forEach(e => {
|
||||||
|
const val = `vm-${e.extension}`;
|
||||||
|
html += `<option value="${escapeHtml(val)}" ${normVal === val ? 'selected' : ''}>Répondeur: ${escapeHtml(e.extension)} — ${escapeHtml(e.name)}</option>`;
|
||||||
|
});
|
||||||
|
html += `</optgroup>`;
|
||||||
|
}
|
||||||
|
|
||||||
// Piège à Spammeurs / Musique d'attente en boucle continue (MoH)
|
// Piège à Spammeurs / Musique d'attente en boucle continue (MoH)
|
||||||
const mohSounds = sounds.filter(s => s.usage_type === 'moh');
|
const mohSounds = sounds.filter(s => s.usage_type === 'moh');
|
||||||
html += `<optgroup label="🎵 Piège à Spammeurs (Musique d'attente en boucle infinie)">`;
|
html += `<optgroup label="🎵 Piège à Spammeurs (Musique d'attente en boucle infinie)">`;
|
||||||
@@ -1863,6 +1966,8 @@ function styleDestinationSelect(selectEl) {
|
|||||||
selectEl.className = 'trunk-did-dest w-full px-2.5 py-1.5 rounded-lg bg-fuchsia-500/15 border border-fuchsia-500/40 text-xs text-fuchsia-300 font-semibold';
|
selectEl.className = 'trunk-did-dest w-full px-2.5 py-1.5 rounded-lg bg-fuchsia-500/15 border border-fuchsia-500/40 text-xs text-fuchsia-300 font-semibold';
|
||||||
} else if (val.startsWith('grp-')) {
|
} else if (val.startsWith('grp-')) {
|
||||||
selectEl.className = 'trunk-did-dest w-full px-2.5 py-1.5 rounded-lg bg-cyan-500/10 border border-cyan-500/40 text-xs text-cyan-300 font-semibold';
|
selectEl.className = 'trunk-did-dest w-full px-2.5 py-1.5 rounded-lg bg-cyan-500/10 border border-cyan-500/40 text-xs text-cyan-300 font-semibold';
|
||||||
|
} else if (val.startsWith('vm-')) {
|
||||||
|
selectEl.className = 'trunk-did-dest w-full px-2.5 py-1.5 rounded-lg bg-violet-500/15 border border-violet-500/40 text-xs text-violet-300 font-semibold';
|
||||||
} else {
|
} else {
|
||||||
selectEl.className = 'trunk-did-dest w-full px-2.5 py-1.5 rounded-lg bg-emerald-500/10 border border-emerald-500/40 text-xs text-emerald-300 font-semibold';
|
selectEl.className = 'trunk-did-dest w-full px-2.5 py-1.5 rounded-lg bg-emerald-500/10 border border-emerald-500/40 text-xs text-emerald-300 font-semibold';
|
||||||
}
|
}
|
||||||
@@ -3523,7 +3628,8 @@ const PAGE_TITLES = {
|
|||||||
certificates: 'Gestionnaire de Certificats SSL / TLS (Let\'s Encrypt & Imports)',
|
certificates: 'Gestionnaire de Certificats SSL / TLS (Let\'s Encrypt & Imports)',
|
||||||
system: 'Paramètres Globaux, Diagnostic Debian & Fichiers .conf',
|
system: 'Paramètres Globaux, Diagnostic Debian & Fichiers .conf',
|
||||||
backups: 'Sauvegardes & Restauration Système (Disaster Recovery)',
|
backups: 'Sauvegardes & Restauration Système (Disaster Recovery)',
|
||||||
firewall: 'Pare-feu Réseau & Sécurité Téléphonie (Fail2ban & NFTables)'
|
firewall: 'Pare-feu Réseau & Sécurité Téléphonie (Fail2ban & NFTables)',
|
||||||
|
cluster: 'Haute Disponibilité & Redondance (Cluster HA Keepalived)'
|
||||||
};
|
};
|
||||||
|
|
||||||
function switchTab(tabId) {
|
function switchTab(tabId) {
|
||||||
@@ -3574,7 +3680,7 @@ function switchTab(tabId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Si on clique sur un sous-menu Système, s'assurer que le menu parent est bien déplié
|
// Si on clique sur un sous-menu Système, s'assurer que le menu parent est bien déplié
|
||||||
if (tabId === 'system' || tabId === 'backups' || tabId === 'firewall') {
|
if (tabId === 'system' || tabId === 'backups' || tabId === 'firewall' || tabId === 'cluster') {
|
||||||
const submenu = document.getElementById('submenu-system');
|
const submenu = document.getElementById('submenu-system');
|
||||||
const arrow = document.getElementById('arrow-menu-system');
|
const arrow = document.getElementById('arrow-menu-system');
|
||||||
if (submenu && submenu.classList.contains('hidden')) {
|
if (submenu && submenu.classList.contains('hidden')) {
|
||||||
@@ -3598,6 +3704,11 @@ function switchTab(tabId) {
|
|||||||
loadFirewallStatus();
|
loadFirewallStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Si onglet Cluster HA, charger les interfaces et le statut en direct
|
||||||
|
if (tabId === 'cluster') {
|
||||||
|
loadCluster();
|
||||||
|
}
|
||||||
|
|
||||||
toggleMobileSidebar(false);
|
toggleMobileSidebar(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4146,6 +4257,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
rtp_checksums: Number(document.getElementById('sip-rtp-checksums')?.value || 1),
|
rtp_checksums: Number(document.getElementById('sip-rtp-checksums')?.value || 1),
|
||||||
rtp_keepalive: Number(document.getElementById('sip-rtp-keepalive')?.value || 15),
|
rtp_keepalive: Number(document.getElementById('sip-rtp-keepalive')?.value || 15),
|
||||||
rtp_timeout: Number(document.getElementById('sip-rtp-timeout')?.value || 30),
|
rtp_timeout: Number(document.getElementById('sip-rtp-timeout')?.value || 30),
|
||||||
|
rtp_timeout_hold: Number(document.getElementById('sip-rtp-timeout-hold')?.value || 0),
|
||||||
allow_anonymous_sip: Number(document.getElementById('sip-allow-anonymous')?.value || 0),
|
allow_anonymous_sip: Number(document.getElementById('sip-allow-anonymous')?.value || 0),
|
||||||
local_networks: localNets
|
local_networks: localNets
|
||||||
};
|
};
|
||||||
@@ -4607,6 +4719,11 @@ function renderSipSettings() {
|
|||||||
rtpTimeoutEl.value = (s.rtp_timeout !== undefined && s.rtp_timeout !== null) ? s.rtp_timeout : 30;
|
rtpTimeoutEl.value = (s.rtp_timeout !== undefined && s.rtp_timeout !== null) ? s.rtp_timeout : 30;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const rtpHoldTimeoutEl = document.getElementById('sip-rtp-timeout-hold');
|
||||||
|
if (rtpHoldTimeoutEl && document.activeElement !== rtpHoldTimeoutEl) {
|
||||||
|
rtpHoldTimeoutEl.value = (s.rtp_timeout_hold !== undefined && s.rtp_timeout_hold !== null) ? s.rtp_timeout_hold : 0;
|
||||||
|
}
|
||||||
|
|
||||||
const allowAnonEl = document.getElementById('sip-allow-anonymous');
|
const allowAnonEl = document.getElementById('sip-allow-anonymous');
|
||||||
if (allowAnonEl) {
|
if (allowAnonEl) {
|
||||||
allowAnonEl.value = (s.allow_anonymous_sip !== undefined && s.allow_anonymous_sip !== null) ? String(s.allow_anonymous_sip) : '0';
|
allowAnonEl.value = (s.allow_anonymous_sip !== undefined && s.allow_anonymous_sip !== null) ? String(s.allow_anonymous_sip) : '0';
|
||||||
@@ -6318,3 +6435,367 @@ async function confirmClearCdr() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ==============================================================================
|
||||||
|
MODULE HAUTE DISPONIBILITÉ (CLUSTER HA ACTIF / PASSIF KEEPALIVED)
|
||||||
|
============================================================================== */
|
||||||
|
|
||||||
|
let currentClusterData = null;
|
||||||
|
|
||||||
|
async function loadCluster() {
|
||||||
|
await loadClusterInterfaces();
|
||||||
|
await refreshClusterStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadClusterInterfaces() {
|
||||||
|
try {
|
||||||
|
const res = await apiCall('cluster_interfaces');
|
||||||
|
const select = document.getElementById('cluster-interface');
|
||||||
|
if (!select || !res.interfaces) return;
|
||||||
|
|
||||||
|
const currentVal = select.value;
|
||||||
|
select.innerHTML = res.interfaces.map(iface => {
|
||||||
|
const ipDisplay = iface.primary ? `(${iface.primary}/${iface.netmask})` : '(pas d\'IP)';
|
||||||
|
return `<option value="${iface.name}">${iface.name} ${ipDisplay}</option>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
if (currentVal && res.interfaces.some(i => i.name === currentVal)) {
|
||||||
|
select.value = currentVal;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Erreur lors du chargement des interfaces réseau :", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshClusterStatus() {
|
||||||
|
try {
|
||||||
|
const res = await apiCall('cluster_status');
|
||||||
|
if (!res.success || !res.status) return;
|
||||||
|
|
||||||
|
const st = res.status;
|
||||||
|
currentClusterData = st;
|
||||||
|
|
||||||
|
// Formulaire - Mise à jour des valeurs
|
||||||
|
const chkEnabled = document.getElementById('cluster-enabled');
|
||||||
|
if (chkEnabled) chkEnabled.checked = Boolean(st.enabled);
|
||||||
|
|
||||||
|
const radioRole = document.querySelector(`input[name="role"][value="${st.configured_role || 'master'}"]`);
|
||||||
|
if (radioRole) radioRole.checked = true;
|
||||||
|
|
||||||
|
const ifaceSelect = document.getElementById('cluster-interface');
|
||||||
|
if (ifaceSelect && st.interface) ifaceSelect.value = st.interface;
|
||||||
|
|
||||||
|
const vipInput = document.getElementById('cluster-virtual-ip');
|
||||||
|
if (vipInput) vipInput.value = st.virtual_ip || '';
|
||||||
|
|
||||||
|
const vmaskInput = document.getElementById('cluster-virtual-netmask');
|
||||||
|
if (vmaskInput && st.virtual_netmask) vmaskInput.value = st.virtual_netmask;
|
||||||
|
|
||||||
|
const vridInput = document.getElementById('cluster-vrid');
|
||||||
|
if (vridInput && st.vrid) vridInput.value = st.vrid;
|
||||||
|
|
||||||
|
const peerIpInput = document.getElementById('cluster-peer-ip');
|
||||||
|
if (peerIpInput) peerIpInput.value = st.peer_ip || '';
|
||||||
|
|
||||||
|
const peerPortInput = document.getElementById('cluster-peer-port');
|
||||||
|
if (peerPortInput && st.peer_port) peerPortInput.value = st.peer_port;
|
||||||
|
|
||||||
|
const autoSyncChk = document.getElementById('cluster-auto-sync');
|
||||||
|
if (autoSyncChk && st.auto_sync !== undefined) autoSyncChk.checked = Boolean(st.auto_sync);
|
||||||
|
|
||||||
|
const secretInput = document.getElementById('cluster-secret');
|
||||||
|
if (secretInput && st.cluster_secret) secretInput.value = st.cluster_secret;
|
||||||
|
|
||||||
|
const blfExpiryContainer = document.getElementById('cluster-blf-expiry-container');
|
||||||
|
const blfExpiryInput = document.getElementById('cluster-blf-expiry');
|
||||||
|
if (blfExpiryContainer && blfExpiryInput) {
|
||||||
|
if (st.enabled) {
|
||||||
|
blfExpiryContainer.classList.remove('hidden');
|
||||||
|
blfExpiryInput.value = st.blf_expiry || 120;
|
||||||
|
} else {
|
||||||
|
blfExpiryContainer.classList.add('hidden');
|
||||||
|
blfExpiryInput.value = st.blf_expiry || 1800;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chkEnabled && !chkEnabled.dataset.blfBound) {
|
||||||
|
chkEnabled.dataset.blfBound = '1';
|
||||||
|
chkEnabled.addEventListener('change', () => {
|
||||||
|
const c = document.getElementById('cluster-blf-expiry-container');
|
||||||
|
const inp = document.getElementById('cluster-blf-expiry');
|
||||||
|
if (chkEnabled.checked) {
|
||||||
|
if (c) c.classList.remove('hidden');
|
||||||
|
if (inp && (!inp.value || inp.value === '1800')) inp.value = 120;
|
||||||
|
} else {
|
||||||
|
if (c) c.classList.add('hidden');
|
||||||
|
if (inp) inp.value = 1800;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Badges & Cartes
|
||||||
|
const headerBadge = document.getElementById('cluster-header-state-badge');
|
||||||
|
const navBadge = document.getElementById('nav-badge-cluster');
|
||||||
|
const cardRoleBadge = document.getElementById('cluster-card-role-badge');
|
||||||
|
const cardState = document.getElementById('cluster-card-state');
|
||||||
|
const cardVipInd = document.getElementById('cluster-card-vip-indicator');
|
||||||
|
const cardIface = document.getElementById('cluster-card-iface');
|
||||||
|
const cardVip = document.getElementById('cluster-card-vip');
|
||||||
|
const cardVrid = document.getElementById('cluster-card-vrid');
|
||||||
|
const cardPeerStatus = document.getElementById('cluster-card-peer-status');
|
||||||
|
const cardPeerIp = document.getElementById('cluster-card-peer-ip');
|
||||||
|
const cardPeerPing = document.getElementById('cluster-card-peer-ping');
|
||||||
|
const cardSyncStatus = document.getElementById('cluster-card-sync-status');
|
||||||
|
const cardSyncTime = document.getElementById('cluster-card-sync-time');
|
||||||
|
|
||||||
|
if (cardVrid) cardVrid.textContent = st.vrid || '51';
|
||||||
|
if (cardIface) cardIface.textContent = `Interface : ${st.interface || 'eth0'}`;
|
||||||
|
if (cardVip) cardVip.textContent = st.virtual_ip || 'Non configurée';
|
||||||
|
|
||||||
|
if (cardRoleBadge) {
|
||||||
|
cardRoleBadge.textContent = (st.configured_role || 'master').toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (st.enabled) {
|
||||||
|
if (st.effective_state === 'MASTER') {
|
||||||
|
if (headerBadge) {
|
||||||
|
headerBadge.className = 'px-2.5 py-0.5 rounded-full text-xs font-bold bg-emerald-500/20 text-emerald-300 border border-emerald-500/30';
|
||||||
|
headerBadge.textContent = 'ACTIF (MASTER)';
|
||||||
|
}
|
||||||
|
if (navBadge) {
|
||||||
|
navBadge.className = 'ml-2 shrink-0 px-1.5 py-0.2 text-[10px] rounded-full bg-emerald-500/20 text-emerald-300 border border-emerald-500/30 font-bold';
|
||||||
|
navBadge.textContent = 'MASTER';
|
||||||
|
}
|
||||||
|
if (cardState) {
|
||||||
|
cardState.className = 'text-lg font-bold text-emerald-400 tracking-wide';
|
||||||
|
cardState.textContent = 'MASTER (ACTIF)';
|
||||||
|
}
|
||||||
|
if (cardVipInd) {
|
||||||
|
cardVipInd.className = 'text-[10px] font-mono px-1.5 py-0.5 rounded bg-emerald-950 text-emerald-300 border border-emerald-800';
|
||||||
|
cardVipInd.textContent = 'VIP: Portée';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (headerBadge) {
|
||||||
|
headerBadge.className = 'px-2.5 py-0.5 rounded-full text-xs font-bold bg-blue-500/20 text-blue-300 border border-blue-500/30';
|
||||||
|
headerBadge.textContent = 'STANDBY (EN VEILLE)';
|
||||||
|
}
|
||||||
|
if (navBadge) {
|
||||||
|
navBadge.className = 'ml-2 shrink-0 px-1.5 py-0.2 text-[10px] rounded-full bg-blue-500/20 text-blue-300 border border-blue-500/30 font-bold';
|
||||||
|
navBadge.textContent = 'STANDBY';
|
||||||
|
}
|
||||||
|
if (cardState) {
|
||||||
|
cardState.className = 'text-lg font-bold text-blue-400 tracking-wide';
|
||||||
|
cardState.textContent = 'STANDBY (VEILLE)';
|
||||||
|
}
|
||||||
|
if (cardVipInd) {
|
||||||
|
cardVipInd.className = 'text-[10px] font-mono px-1.5 py-0.5 rounded bg-anthracite-900 text-slate-400';
|
||||||
|
cardVipInd.textContent = 'VIP: Écoute';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (headerBadge) {
|
||||||
|
headerBadge.className = 'px-2.5 py-0.5 rounded-full text-xs font-bold bg-slate-700 text-slate-300';
|
||||||
|
headerBadge.textContent = 'DÉSACTIVÉ';
|
||||||
|
}
|
||||||
|
if (navBadge) {
|
||||||
|
navBadge.className = 'ml-2 shrink-0 px-1.5 py-0.2 text-[10px] rounded-full bg-anthracite-800 text-slate-400 border border-anthracite-700 font-bold';
|
||||||
|
navBadge.textContent = 'OFF';
|
||||||
|
}
|
||||||
|
if (cardState) {
|
||||||
|
cardState.className = 'text-lg font-bold text-slate-400 tracking-wide';
|
||||||
|
cardState.textContent = 'STANDALONE';
|
||||||
|
}
|
||||||
|
if (cardVipInd) {
|
||||||
|
cardVipInd.className = 'text-[10px] font-mono px-1.5 py-0.5 rounded bg-anthracite-900 text-slate-400';
|
||||||
|
cardVipInd.textContent = 'VIP: Inactive';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nœud Distant (Peer)
|
||||||
|
if (cardPeerIp) cardPeerIp.textContent = st.peer_ip || 'Non configuré';
|
||||||
|
if (cardPeerStatus) {
|
||||||
|
if (!st.peer_ip) {
|
||||||
|
cardPeerStatus.className = 'px-2 py-0.5 rounded text-[10px] font-bold bg-slate-700 text-slate-300';
|
||||||
|
cardPeerStatus.textContent = 'NON DÉFINI';
|
||||||
|
} else if (st.peer_reachable) {
|
||||||
|
cardPeerStatus.className = 'px-2 py-0.5 rounded text-[10px] font-bold bg-emerald-500/20 text-emerald-300 border border-emerald-500/30';
|
||||||
|
cardPeerStatus.textContent = 'JOIGNABLE';
|
||||||
|
} else {
|
||||||
|
cardPeerStatus.className = 'px-2 py-0.5 rounded text-[10px] font-bold bg-red-500/20 text-red-300 border border-red-500/30';
|
||||||
|
cardPeerStatus.textContent = 'INJOIGNABLE';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cardPeerPing) {
|
||||||
|
cardPeerPing.textContent = (st.peer_ping_ms !== null) ? `Latence ping : ${st.peer_ping_ms.toFixed(1)} ms` : 'Latence ping : Inaccessible';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Synchronisation
|
||||||
|
if (cardSyncStatus) {
|
||||||
|
if (st.last_sync_status === 'success' || st.last_sync_status === 'synced') {
|
||||||
|
cardSyncStatus.className = 'text-sm font-bold text-emerald-400 truncate';
|
||||||
|
cardSyncStatus.textContent = 'Synchronisation OK';
|
||||||
|
} else if (st.last_sync_status === 'error') {
|
||||||
|
cardSyncStatus.className = 'text-sm font-bold text-red-400 truncate';
|
||||||
|
cardSyncStatus.textContent = 'Erreur réplication';
|
||||||
|
} else {
|
||||||
|
cardSyncStatus.className = 'text-sm font-bold text-slate-400 truncate';
|
||||||
|
cardSyncStatus.textContent = 'En attente';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cardSyncTime) {
|
||||||
|
cardSyncTime.textContent = st.last_sync_at ? `Dernière synchro : ${st.last_sync_at}` : 'Dernière synchro : Jamais';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Services Système (Diagnostic)
|
||||||
|
const svcKeepalived = document.getElementById('cluster-svc-keepalived');
|
||||||
|
if (svcKeepalived) {
|
||||||
|
if (!st.keepalived_installed) {
|
||||||
|
svcKeepalived.className = 'font-mono text-amber-400 font-bold';
|
||||||
|
svcKeepalived.textContent = '⚠️ Non installé';
|
||||||
|
} else if (st.keepalived_running) {
|
||||||
|
svcKeepalived.className = 'font-mono text-emerald-400 font-bold';
|
||||||
|
svcKeepalived.textContent = '🟢 Actif (En cours)';
|
||||||
|
} else {
|
||||||
|
svcKeepalived.className = 'font-mono text-slate-400';
|
||||||
|
svcKeepalived.textContent = '⚪ Arrêté';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const svcAsterisk = document.getElementById('cluster-svc-asterisk');
|
||||||
|
if (svcAsterisk) {
|
||||||
|
if (st.asterisk_healthy) {
|
||||||
|
svcAsterisk.className = 'font-mono text-emerald-400 font-bold';
|
||||||
|
svcAsterisk.textContent = '🟢 En ligne (Ping Pong OK)';
|
||||||
|
} else if (st.asterisk_running) {
|
||||||
|
svcAsterisk.className = 'font-mono text-amber-400 font-bold';
|
||||||
|
svcAsterisk.textContent = '🟡 En cours (Ping en attente)';
|
||||||
|
} else {
|
||||||
|
svcAsterisk.className = 'font-mono text-red-400 font-bold';
|
||||||
|
svcAsterisk.textContent = '🔴 Arrêté';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const svcPeer = document.getElementById('cluster-svc-peer');
|
||||||
|
if (svcPeer) {
|
||||||
|
if (!st.peer_ip) {
|
||||||
|
svcPeer.textContent = 'Non configuré';
|
||||||
|
svcPeer.className = 'font-mono text-slate-500';
|
||||||
|
} else if (st.peer_reachable) {
|
||||||
|
svcPeer.textContent = `🟢 Joignable (${st.peer_ping_ms !== null ? st.peer_ping_ms.toFixed(1) + 'ms' : 'OK'})`;
|
||||||
|
svcPeer.className = 'font-mono text-emerald-400 font-bold';
|
||||||
|
} else {
|
||||||
|
svcPeer.textContent = '🔴 Injoignable (Timeout)';
|
||||||
|
svcPeer.className = 'font-mono text-red-400 font-bold';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Journal de logs
|
||||||
|
const logsContainer = document.getElementById('cluster-logs-container');
|
||||||
|
if (logsContainer && st.recent_logs) {
|
||||||
|
if (st.recent_logs.length > 0) {
|
||||||
|
logsContainer.innerHTML = st.recent_logs.map(line => {
|
||||||
|
let color = 'text-slate-300';
|
||||||
|
if (line.includes('MASTER')) color = 'text-emerald-300 font-bold';
|
||||||
|
if (line.includes('BACKUP') || line.includes('STANDBY')) color = 'text-blue-300';
|
||||||
|
if (line.includes('FAULT') || line.includes('ALERTE')) color = 'text-red-400 font-bold';
|
||||||
|
return `<div class="${color}">${escapeHtml(line)}</div>`;
|
||||||
|
}).join('');
|
||||||
|
} else {
|
||||||
|
logsContainer.innerHTML = '<p class="text-slate-500">Aucun événement enregistré.</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Erreur lors du rafraîchissement du cluster :", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveClusterSettings(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const form = e.target;
|
||||||
|
const payload = {
|
||||||
|
enabled: form.elements['enabled'].checked ? 1 : 0,
|
||||||
|
role: form.elements['role'].value,
|
||||||
|
interface: form.elements['interface'].value,
|
||||||
|
virtual_ip: form.elements['virtual_ip'].value.trim(),
|
||||||
|
virtual_netmask: form.elements['virtual_netmask'].value.trim() || '24',
|
||||||
|
peer_ip: form.elements['peer_ip'].value.trim(),
|
||||||
|
peer_port: parseInt(form.elements['peer_port'] ? form.elements['peer_port'].value : 8443, 10) || 8443,
|
||||||
|
cluster_secret: form.elements['cluster_secret'].value.trim(),
|
||||||
|
vrid: parseInt(form.elements['vrid'].value, 10) || 51,
|
||||||
|
blf_expiry: parseInt(form.elements['blf_expiry'] ? form.elements['blf_expiry'].value : 120, 10) || 120,
|
||||||
|
auto_sync: form.elements['auto_sync'].checked ? 1 : 0
|
||||||
|
};
|
||||||
|
|
||||||
|
if (payload.enabled && !payload.virtual_ip) {
|
||||||
|
showToast("L'adresse IP Virtuelle Flottante (VIP) est requise pour activer le cluster.", 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await apiCall('cluster_save', payload);
|
||||||
|
showToast(res.message || "Paramètres du cluster enregistrés.", 'success');
|
||||||
|
refreshClusterStatus();
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message || "Erreur lors de l'enregistrement du cluster.", 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function triggerClusterSyncNow() {
|
||||||
|
if (!currentClusterData || !currentClusterData.peer_ip) {
|
||||||
|
showToast("Veuillez d'abord renseigner et enregistrer l'adresse IP du Nœud Pair (Peer IP).", 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showToast("Déclenchement de la réplication vers le pair...", 'info');
|
||||||
|
try {
|
||||||
|
const res = await apiCall('cluster_sync_now', {});
|
||||||
|
showToast(res.message || "Synchronisation réussie avec le nœud pair !", 'success');
|
||||||
|
refreshClusterStatus();
|
||||||
|
} catch (e) {
|
||||||
|
showToast(e.message || "Erreur lors de la synchronisation.", 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function triggerClusterSwitchover() {
|
||||||
|
if (!currentClusterData || !currentClusterData.enabled) {
|
||||||
|
showToast("Le cluster doit être activé pour effectuer une bascule.", 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const currentRole = currentClusterData.configured_role || 'master';
|
||||||
|
const targetRole = (currentRole === 'master') ? 'STANDBY' : 'MASTER';
|
||||||
|
|
||||||
|
if (!confirm(`Confirmez-vous la bascule manuelle de rôle (Switchover) vers l'état ${targetRole} ?\nL'adresse IP flottante va être transférée.`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await apiCall('cluster_switchover', {});
|
||||||
|
showToast(res.message || `Bascule effectuée. Nouveau rôle : ${targetRole}`, 'success');
|
||||||
|
refreshClusterStatus();
|
||||||
|
} catch (e) {
|
||||||
|
showToast(e.message || "Erreur lors de la bascule.", 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function regenerateClusterSecret() {
|
||||||
|
const secretInput = document.getElementById('cluster-secret');
|
||||||
|
if (!secretInput) return;
|
||||||
|
const array = new Uint8Array(16);
|
||||||
|
window.crypto.getRandomValues(array);
|
||||||
|
const newSecret = Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('');
|
||||||
|
secretInput.value = newSecret;
|
||||||
|
showToast("Nouveau jeton généré. N'oubliez pas d'enregistrer et de copier cette même clé sur le second serveur !", 'info');
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyClusterSecret() {
|
||||||
|
const secretInput = document.getElementById('cluster-secret');
|
||||||
|
if (!secretInput || !secretInput.value) return;
|
||||||
|
navigator.clipboard.writeText(secretInput.value).then(() => {
|
||||||
|
showToast("Jeton secret copié dans le presse-papier !", 'success');
|
||||||
|
}).catch(() => {
|
||||||
|
secretInput.select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
showToast("Jeton secret copié !", 'success');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
+390
-6
@@ -4,6 +4,16 @@ declare(strict_types=1);
|
|||||||
require_once __DIR__ . '/../src/Database.php';
|
require_once __DIR__ . '/../src/Database.php';
|
||||||
require_once __DIR__ . '/../src/Auth.php';
|
require_once __DIR__ . '/../src/Auth.php';
|
||||||
|
|
||||||
|
// Autoloader PSR-4 automatique pour toutes les classes du namespace App
|
||||||
|
spl_autoload_register(function (string $class): void {
|
||||||
|
if (str_starts_with($class, 'App\\')) {
|
||||||
|
$file = __DIR__ . '/../src/' . str_replace('\\', '/', substr($class, 4)) . '.php';
|
||||||
|
if (file_exists($file)) {
|
||||||
|
require_once $file;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
use App\Database;
|
use App\Database;
|
||||||
use App\Auth;
|
use App\Auth;
|
||||||
|
|
||||||
@@ -18,7 +28,12 @@ $companyLogo = trim($branding['company_logo'] ?? '');
|
|||||||
$hasBranding = !empty($companyName) || !empty($companyLogo);
|
$hasBranding = !empty($companyName) || !empty($companyLogo);
|
||||||
|
|
||||||
$appVerData = file_exists(__DIR__ . '/../version.json') ? json_decode((string)file_get_contents(__DIR__ . '/../version.json'), true) : [];
|
$appVerData = file_exists(__DIR__ . '/../version.json') ? json_decode((string)file_get_contents(__DIR__ . '/../version.json'), true) : [];
|
||||||
$appVersion = $appVerData['version'] ?? '1.5.0';
|
$appVersion = $appVerData['version'] ?? '2.0.0';
|
||||||
|
$appCodename = $appVerData['codename'] ?? 'Pulsar';
|
||||||
|
|
||||||
|
// Détection de l'environnement de développement : présence de .dev local ou branche git dev
|
||||||
|
$isDevEnv = file_exists(__DIR__ . '/../.dev')
|
||||||
|
|| (trim((string)@shell_exec('git -C ' . escapeshellarg(__DIR__) . ' rev-parse --abbrev-ref HEAD 2>/dev/null')) === 'dev');
|
||||||
?>
|
?>
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="fr" class="h-full">
|
<html lang="fr" class="h-full">
|
||||||
@@ -26,7 +41,7 @@ $appVersion = $appVerData['version'] ?? '1.5.0';
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<meta name="nexus-app-version" content="<?= htmlspecialchars($appVersion) ?>">
|
<meta name="nexus-app-version" content="<?= htmlspecialchars($appVersion) ?>">
|
||||||
<title>NexusPBX — Supervision & Téléphonie IP • Powered by Asterisk</title>
|
<title><?= $isDevEnv ? '🛠️ [DEV] ' : '' ?>NexusPBX — Supervision & Téléphonie IP • Powered by Asterisk</title>
|
||||||
<link rel="icon" type="image/png" href="/assets/favicon.png?v=<?= file_exists(__DIR__ . '/assets/favicon.png') ? filemtime(__DIR__ . '/assets/favicon.png') : 1 ?>">
|
<link rel="icon" type="image/png" href="/assets/favicon.png?v=<?= file_exists(__DIR__ . '/assets/favicon.png') ? filemtime(__DIR__ . '/assets/favicon.png') : 1 ?>">
|
||||||
<link rel="apple-touch-icon" href="/assets/logo.png?v=<?= file_exists(__DIR__ . '/assets/logo.png') ? filemtime(__DIR__ . '/assets/logo.png') : 1 ?>">
|
<link rel="apple-touch-icon" href="/assets/logo.png?v=<?= file_exists(__DIR__ . '/assets/logo.png') ? filemtime(__DIR__ . '/assets/logo.png') : 1 ?>">
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
@@ -162,7 +177,12 @@ $appVersion = $appVerData['version'] ?? '1.5.0';
|
|||||||
<div class="flex items-center gap-4 mb-6">
|
<div class="flex items-center gap-4 mb-6">
|
||||||
<img src="/assets/logo.png?v=<?= file_exists(__DIR__ . '/assets/logo.png') ? filemtime(__DIR__ . '/assets/logo.png') : 1 ?>" alt="NexusPBX Logo" class="w-20 h-20 sm:w-24 sm:h-24 object-contain shrink-0 filter drop-shadow-xl">
|
<img src="/assets/logo.png?v=<?= file_exists(__DIR__ . '/assets/logo.png') ? filemtime(__DIR__ . '/assets/logo.png') : 1 ?>" alt="NexusPBX Logo" class="w-20 h-20 sm:w-24 sm:h-24 object-contain shrink-0 filter drop-shadow-xl">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl sm:text-3xl font-black text-white tracking-tight leading-none mb-1">NexusPBX</h1>
|
<h1 class="text-2xl sm:text-3xl font-black text-white tracking-tight leading-none mb-1 flex items-center gap-2">
|
||||||
|
NexusPBX
|
||||||
|
<?php if ($isDevEnv): ?>
|
||||||
|
<span class="px-2 py-0.5 text-xs font-mono font-extrabold tracking-wider rounded bg-amber-500/20 text-amber-300 border border-amber-500/40">DEV</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</h1>
|
||||||
<p class="text-xs text-slate-300">Supervision & Téléphonie IP • Powered by Asterisk</p>
|
<p class="text-xs text-slate-300">Supervision & Téléphonie IP • Powered by Asterisk</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -211,7 +231,10 @@ $appVersion = $appVerData['version'] ?? '1.5.0';
|
|||||||
<div class="font-black text-white text-2xl tracking-tight leading-none">
|
<div class="font-black text-white text-2xl tracking-tight leading-none">
|
||||||
Nexus<span class="text-indigo-400">PBX</span>
|
Nexus<span class="text-indigo-400">PBX</span>
|
||||||
</div>
|
</div>
|
||||||
<span id="app-version-badge" class="px-1.5 py-0.5 text-[10px] font-mono font-bold tracking-wider rounded bg-indigo-500/20 text-indigo-300 border border-indigo-500/30">v<?= htmlspecialchars($appVersion) ?></span>
|
<span id="app-version-badge" class="px-1.5 py-0.5 text-[10px] font-mono font-bold tracking-wider rounded bg-indigo-500/20 text-indigo-300 border border-indigo-500/30">v<?= htmlspecialchars($appVersion) ?> <span class="hidden sm:inline font-sans font-normal opacity-80">« <?= htmlspecialchars($appCodename) ?> »</span></span>
|
||||||
|
<?php if ($isDevEnv): ?>
|
||||||
|
<span class="px-1.5 py-0.5 text-[10px] font-mono font-black tracking-wider rounded bg-amber-500/25 text-amber-300 border border-amber-500/50 shadow-xs shadow-amber-500/20 animate-pulse">DEV</span>
|
||||||
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-[10px] text-slate-400 font-medium tracking-wide mt-0.5">Powered by Asterisk</div>
|
<div class="text-[10px] text-slate-400 font-medium tracking-wide mt-0.5">Powered by Asterisk</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -442,6 +465,11 @@ $appVersion = $appVerData['version'] ?? '1.5.0';
|
|||||||
<span class="truncate">• Pare-feu & Fail2ban</span>
|
<span class="truncate">• Pare-feu & Fail2ban</span>
|
||||||
<span id="nav-badge-firewall" class="ml-2 shrink-0 px-1.5 py-0.2 text-[10px] rounded-full bg-anthracite-800 text-emerald-300 border border-anthracite-700 font-bold">0</span>
|
<span id="nav-badge-firewall" class="ml-2 shrink-0 px-1.5 py-0.2 text-[10px] rounded-full bg-anthracite-800 text-emerald-300 border border-anthracite-700 font-bold">0</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" data-tab="cluster" onclick="switchTab('cluster')"
|
||||||
|
class="tab-btn w-full flex items-center justify-between px-3 py-1.5 rounded-lg text-xs font-medium text-slate-300 hover:bg-anthracite-800 hover:text-white transition text-left">
|
||||||
|
<span class="truncate">• Haute Disponibilité (HA)</span>
|
||||||
|
<span id="nav-badge-cluster" class="ml-2 shrink-0 px-1.5 py-0.2 text-[10px] rounded-full bg-anthracite-800 text-cyan-300 border border-anthracite-700 font-bold">HA</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -494,6 +522,13 @@ $appVersion = $appVerData['version'] ?? '1.5.0';
|
|||||||
|
|
||||||
<!-- Boutons d'action globaux -->
|
<!-- Boutons d'action globaux -->
|
||||||
<div class="flex items-center gap-2.5">
|
<div class="flex items-center gap-2.5">
|
||||||
|
<?php if ($isDevEnv): ?>
|
||||||
|
<div class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-bold bg-amber-500/15 text-amber-300 border border-amber-500/30">
|
||||||
|
<span class="w-2 h-2 rounded-full bg-amber-400 animate-pulse"></span>
|
||||||
|
<span class="hidden md:inline font-mono">SERVEUR</span> DEV
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<!-- Badge Notification Mise à Jour Disponible -->
|
<!-- Badge Notification Mise à Jour Disponible -->
|
||||||
<button id="header-update-badge" type="button" onclick="openUpdatePanel()" style="display:none;"
|
<button id="header-update-badge" type="button" onclick="openUpdatePanel()" style="display:none;"
|
||||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-bold bg-amber-500/20 hover:bg-amber-500/30 text-amber-300 border border-amber-500/40 shadow-sm transition animate-pulse" title="Une mise à jour NexusPBX est disponible ! Cliquez pour voir les détails.">
|
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-bold bg-amber-500/20 hover:bg-amber-500/30 text-amber-300 border border-amber-500/40 shadow-sm transition animate-pulse" title="Une mise à jour NexusPBX est disponible ! Cliquez pour voir les détails.">
|
||||||
@@ -1745,7 +1780,7 @@ $appVersion = $appVerData['version'] ?? '1.5.0';
|
|||||||
<span id="update-dot" class="relative inline-flex rounded-full h-3 w-3 bg-emerald-500"></span>
|
<span id="update-dot" class="relative inline-flex rounded-full h-3 w-3 bg-emerald-500"></span>
|
||||||
</span>
|
</span>
|
||||||
<h3 class="text-base font-bold text-white">Mises à Jour du Système NexusPBX</h3>
|
<h3 class="text-base font-bold text-white">Mises à Jour du Système NexusPBX</h3>
|
||||||
<span id="system-version-tag" class="px-2 py-0.5 text-xs font-mono font-bold rounded-lg bg-indigo-500/20 text-indigo-300 border border-indigo-500/30">v<?= htmlspecialchars($appVersion) ?></span>
|
<span id="system-version-tag" class="px-2 py-0.5 text-xs font-mono font-bold rounded-lg bg-indigo-500/20 text-indigo-300 border border-indigo-500/30">v<?= htmlspecialchars($appVersion) ?> — <?= htmlspecialchars($appCodename) ?></span>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-slate-300 mt-1">Vérifiez les nouvelles versions officielles et mettez à jour votre PBX en 1 clic sans coupure de service.</p>
|
<p class="text-xs text-slate-300 mt-1">Vérifiez les nouvelles versions officielles et mettez à jour votre PBX en 1 clic sans coupure de service.</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -2520,7 +2555,7 @@ $appVersion = $appVerData['version'] ?? '1.5.0';
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 pt-2">
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 pt-2">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-semibold text-slate-200 mb-1">RTP Keepalive (secondes)</label>
|
<label class="block text-xs font-semibold text-slate-200 mb-1">RTP Keepalive (secondes)</label>
|
||||||
<input type="number" id="sip-rtp-keepalive" name="rtp_keepalive" value="15" min="0" max="120"
|
<input type="number" id="sip-rtp-keepalive" name="rtp_keepalive" value="15" min="0" max="120"
|
||||||
@@ -2533,6 +2568,12 @@ $appVersion = $appVerData['version'] ?? '1.5.0';
|
|||||||
class="w-full px-3 py-2 rounded-xl bg-anthracite-950 border border-anthracite-700 text-xs text-white font-mono focus:ring-2 focus:ring-emerald-500 focus:outline-none">
|
class="w-full px-3 py-2 rounded-xl bg-anthracite-950 border border-anthracite-700 text-xs text-white font-mono focus:ring-2 focus:ring-emerald-500 focus:outline-none">
|
||||||
<p class="text-[11px] text-slate-400 mt-1">Durée avant coupure si aucun flux voix n'est reçu (raccroché distant brutal).</p>
|
<p class="text-[11px] text-slate-400 mt-1">Durée avant coupure si aucun flux voix n'est reçu (raccroché distant brutal).</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-semibold text-slate-200 mb-1">RTP Timeout Mise en attente</label>
|
||||||
|
<input type="number" id="sip-rtp-timeout-hold" name="rtp_timeout_hold" value="0" min="0" max="3600"
|
||||||
|
class="w-full px-3 py-2 rounded-xl bg-anthracite-950 border border-anthracite-700 text-xs text-white font-mono focus:ring-2 focus:ring-emerald-500 focus:outline-none">
|
||||||
|
<p class="text-[11px] text-slate-400 mt-1">Délai max d'un appel en attente sans flux RTP (0 = illimité / désactivé).</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -2736,6 +2777,265 @@ $appVersion = $appVerData['version'] ?? '1.5.0';
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- ========================================================================= -->
|
||||||
|
<!-- ONGLET 16 : HAUTE DISPONIBILITÉ & CLUSTER HA (ACTIF / PASSIF KEEPALIVED) -->
|
||||||
|
<!-- ========================================================================= -->
|
||||||
|
<section id="tab-cluster" class="tab-panel hidden space-y-6">
|
||||||
|
<!-- EN-TÊTE DE LA SECTION -->
|
||||||
|
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 bg-anthracite-800 p-6 rounded-2xl border border-anthracite-700">
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-2 mb-1">
|
||||||
|
<span class="px-2.5 py-0.5 rounded-full text-xs font-bold bg-cyan-500/20 text-cyan-300 border border-cyan-500/30">CLUSTER HA</span>
|
||||||
|
<span id="cluster-header-state-badge" class="px-2.5 py-0.5 rounded-full text-xs font-bold bg-slate-700 text-slate-300">DÉSACTIVÉ</span>
|
||||||
|
</div>
|
||||||
|
<h2 class="text-xl font-black text-white tracking-wide">Haute Disponibilité (Cluster HA Actif / Passif)</h2>
|
||||||
|
<p class="text-xs text-slate-300 mt-1">Bascule automatique en < 3 secondes par IP Virtuelle Flottante (VRRP / Keepalived) et synchronisation temps réel.</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button type="button" onclick="refreshClusterStatus()"
|
||||||
|
class="px-3.5 py-2 rounded-xl text-xs font-bold text-slate-300 bg-anthracite-900 border border-anthracite-700 hover:text-white hover:border-slate-500 transition flex items-center gap-2">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
|
||||||
|
<span>Actualiser</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" onclick="triggerClusterSwitchover()" id="btn-cluster-switchover"
|
||||||
|
class="px-3.5 py-2 rounded-xl text-xs font-bold text-amber-300 bg-amber-500/10 border border-amber-500/30 hover:bg-amber-500/20 transition flex items-center gap-2">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4"/></svg>
|
||||||
|
<span>Bascule Manuelle (Switchover)</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CARTES STATISTIQUES / ÉTAT EN TEMPS RÉEL -->
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<!-- CARTE 1 : ÉTAT NŒUD LOCAL -->
|
||||||
|
<div class="p-4 rounded-xl bg-anthracite-800 border border-anthracite-700 space-y-2">
|
||||||
|
<div class="flex items-center justify-between text-xs text-slate-400">
|
||||||
|
<span>Nœud Local</span>
|
||||||
|
<span id="cluster-card-role-badge" class="px-2 py-0.5 rounded text-[10px] font-bold bg-slate-700 text-slate-300">MASTER</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-baseline justify-between">
|
||||||
|
<span id="cluster-card-state" class="text-lg font-bold text-white tracking-wide">STANDALONE</span>
|
||||||
|
<span id="cluster-card-vip-indicator" class="text-[10px] font-mono px-1.5 py-0.5 rounded bg-anthracite-900 text-slate-400">VIP: Inactive</span>
|
||||||
|
</div>
|
||||||
|
<p id="cluster-card-iface" class="text-[11px] text-slate-400 font-mono">Interface : ens18</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CARTE 2 : ADRESSE IP VIRTUELLE (VIP) -->
|
||||||
|
<div class="p-4 rounded-xl bg-anthracite-800 border border-anthracite-700 space-y-2">
|
||||||
|
<div class="flex items-center justify-between text-xs text-slate-400">
|
||||||
|
<span>IP Virtuelle (VIP)</span>
|
||||||
|
<span class="text-[10px] font-bold text-cyan-400">VRRP ID: <span id="cluster-card-vrid">51</span></span>
|
||||||
|
</div>
|
||||||
|
<div class="text-lg font-bold font-mono text-cyan-300 tracking-wide" id="cluster-card-vip">
|
||||||
|
Non configurée
|
||||||
|
</div>
|
||||||
|
<p class="text-[11px] text-slate-400">Point de connexion des Postes & Trunks</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CARTE 3 : NŒUD DISTANT (PEER) -->
|
||||||
|
<div class="p-4 rounded-xl bg-anthracite-800 border border-anthracite-700 space-y-2">
|
||||||
|
<div class="flex items-center justify-between text-xs text-slate-400">
|
||||||
|
<span>Nœud Distant (Pair)</span>
|
||||||
|
<span id="cluster-card-peer-status" class="px-2 py-0.5 rounded text-[10px] font-bold bg-slate-700 text-slate-300">INCONNU</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-base font-bold font-mono text-white tracking-wide truncate" id="cluster-card-peer-ip">
|
||||||
|
Non configuré
|
||||||
|
</div>
|
||||||
|
<p id="cluster-card-peer-ping" class="text-[11px] text-slate-400 font-mono">Latence ping : —</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CARTE 4 : RÉPLICATION DES DONNÉES -->
|
||||||
|
<div class="p-4 rounded-xl bg-anthracite-800 border border-anthracite-700 space-y-2">
|
||||||
|
<div class="flex items-center justify-between text-xs text-slate-400">
|
||||||
|
<span>Synchronisation</span>
|
||||||
|
<button type="button" onclick="triggerClusterSyncNow()" class="text-[10px] text-indigo-400 hover:text-indigo-300 font-bold underline">Sync Now</button>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm font-bold text-white truncate" id="cluster-card-sync-status">
|
||||||
|
Jamais synchronisé
|
||||||
|
</div>
|
||||||
|
<p id="cluster-card-sync-time" class="text-[11px] text-slate-400 font-mono">Dernier échange : —</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CONTENU PRINCIPAL : FORMULAIRE & ACTIONS -->
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
<!-- FORMULAIRE DE CONFIGURATION DU CLUSTER (2/3) -->
|
||||||
|
<div class="lg:col-span-2 bg-anthracite-800 p-6 rounded-2xl border border-anthracite-700 space-y-5">
|
||||||
|
<h3 class="text-sm font-bold text-white uppercase tracking-wider flex items-center gap-2">
|
||||||
|
<svg class="w-4 h-4 text-cyan-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"/></svg>
|
||||||
|
<span>Paramétrage de la Haute Disponibilité (Keepalived VRRP)</span>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<form id="form-cluster-settings" onsubmit="saveClusterSettings(event)" class="space-y-4">
|
||||||
|
<!-- SWITCH ACTIVATION -->
|
||||||
|
<div class="flex items-center justify-between p-3.5 rounded-xl bg-anthracite-900/80 border border-anthracite-700">
|
||||||
|
<div>
|
||||||
|
<label for="cluster-enabled" class="text-sm font-bold text-white cursor-pointer">Activer le Cluster Haute Disponibilité</label>
|
||||||
|
<p class="text-xs text-slate-400">Active Keepalived pour surveiller Asterisk et partager l'IP flottante.</p>
|
||||||
|
</div>
|
||||||
|
<label class="relative inline-flex items-center cursor-pointer">
|
||||||
|
<input type="checkbox" id="cluster-enabled" name="enabled" value="1" class="sr-only peer">
|
||||||
|
<div class="w-11 h-6 bg-anthracite-700 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-slate-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-cyan-500"></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- RÔLE DU SERVEUR -->
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label class="block text-xs font-bold text-slate-300 uppercase">Rôle initial de ce serveur</label>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
<label class="flex items-center gap-3 p-3 rounded-xl border border-anthracite-700 bg-anthracite-900/60 cursor-pointer hover:border-slate-500 transition">
|
||||||
|
<input type="radio" name="role" value="master" class="text-cyan-500 focus:ring-cyan-400" checked>
|
||||||
|
<div>
|
||||||
|
<span class="text-xs font-bold text-white">Nœud 1 : Master (Primaire)</span>
|
||||||
|
<p class="text-[10px] text-slate-400">Priorité VRRP 100 • Porte la VIP au démarrage.</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-3 p-3 rounded-xl border border-anthracite-700 bg-anthracite-900/60 cursor-pointer hover:border-slate-500 transition">
|
||||||
|
<input type="radio" name="role" value="backup" class="text-cyan-500 focus:ring-cyan-400">
|
||||||
|
<div>
|
||||||
|
<span class="text-xs font-bold text-white">Nœud 2 : Standby (Secondaire)</span>
|
||||||
|
<p class="text-[10px] text-slate-400">Priorité VRRP 90 • Prend le relais si le Master tombe.</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- INTERFACE RÉSEAU & IP VIRTUELLE -->
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||||
|
<div class="sm:col-span-1">
|
||||||
|
<label class="block text-xs font-bold text-slate-300 uppercase mb-1">Carte Réseau</label>
|
||||||
|
<select id="cluster-interface" name="interface" required
|
||||||
|
class="w-full px-3 py-2 rounded-xl bg-anthracite-900 border border-anthracite-700 text-sm text-white focus:outline-none focus:border-cyan-400 font-mono">
|
||||||
|
<option value="eth0">eth0</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="sm:col-span-2">
|
||||||
|
<label class="block text-xs font-bold text-slate-300 uppercase mb-1">IP Virtuelle Flottante (VIP)</label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input type="text" id="cluster-virtual-ip" name="virtual_ip" placeholder="ex: 10.10.0.100"
|
||||||
|
class="flex-1 px-3 py-2 rounded-xl bg-anthracite-900 border border-anthracite-700 text-sm text-white focus:outline-none focus:border-cyan-400 font-mono">
|
||||||
|
<span class="flex items-center text-slate-400 font-mono text-sm">/</span>
|
||||||
|
<input type="text" id="cluster-virtual-netmask" name="virtual_netmask" value="24" placeholder="24"
|
||||||
|
class="w-16 px-2 py-2 rounded-xl bg-anthracite-900 border border-anthracite-700 text-sm text-white text-center focus:outline-none focus:border-cyan-400 font-mono">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- IP DU PAIR & JETON SECRET -->
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-bold text-slate-300 uppercase mb-1">IP & Port d'Administration du Pair</label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input type="text" id="cluster-peer-ip" name="peer_ip" placeholder="ex: 10.10.0.5"
|
||||||
|
class="flex-1 px-3 py-2 rounded-xl bg-anthracite-900 border border-anthracite-700 text-sm text-white focus:outline-none focus:border-cyan-400 font-mono">
|
||||||
|
<span class="flex items-center text-slate-400 font-mono text-sm">:</span>
|
||||||
|
<input type="number" id="cluster-peer-port" name="peer_port" value="8443" min="1" max="65535" placeholder="8443"
|
||||||
|
class="w-24 px-3 py-2 rounded-xl bg-anthracite-900 border border-anthracite-700 text-sm text-white text-center focus:outline-none focus:border-cyan-400 font-mono" title="Port HTTPS d'administration du serveur distant (ex: 8443 ou 2356)">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-bold text-slate-300 uppercase mb-1">Jeton de Sécurité Cluster (Secret)</label>
|
||||||
|
<div class="flex gap-1.5">
|
||||||
|
<input type="text" id="cluster-secret" name="cluster_secret" required
|
||||||
|
class="flex-1 px-3 py-2 rounded-xl bg-anthracite-900 border border-anthracite-700 text-xs text-white font-mono focus:outline-none focus:border-cyan-400">
|
||||||
|
<button type="button" onclick="regenerateClusterSecret()" title="Générer un nouveau token"
|
||||||
|
class="px-2.5 py-2 rounded-xl bg-anthracite-900 border border-anthracite-700 text-slate-300 hover:text-white transition">🎲</button>
|
||||||
|
<button type="button" onclick="copyClusterSecret()" title="Copier le token"
|
||||||
|
class="px-2.5 py-2 rounded-xl bg-anthracite-900 border border-anthracite-700 text-slate-300 hover:text-white transition">📋</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- DÉLAI D'EXPIRATION / RÉENREGISTREMENT POSTES (REGISTER HA) -->
|
||||||
|
<div id="cluster-blf-expiry-container" class="hidden p-3.5 rounded-xl bg-anthracite-900/80 border border-cyan-500/30 space-y-1">
|
||||||
|
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="flex items-center gap-2 mb-0.5">
|
||||||
|
<label for="cluster-blf-expiry" class="text-xs font-bold text-white cursor-pointer">Intervalle de réenregistrement des Postes (REGISTER)</label>
|
||||||
|
<span class="px-2 py-0.5 rounded text-[10px] font-bold bg-cyan-500/20 text-cyan-300 border border-cyan-500/30">Recommandé HA : 120s</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-[11px] text-slate-400 leading-relaxed">
|
||||||
|
Plafond d'expiration imposé aux téléphones pour le renouvellement de leur enregistrement SIP (<code class="text-cyan-300 font-mono text-[10px]">REGISTER</code>). En cas de bascule HA, ce délai force les postes à se réenregistrer rapidement sur le serveur actif (ex. toutes les 60s pour un plafond de 120s) afin de rétablir les appels entrants et sortants sans attendre (par défaut 1800s sans HA).
|
||||||
|
</p>
|
||||||
|
<p class="text-[10px] text-amber-400/90 leading-normal flex items-start gap-1.5 pt-0.5">
|
||||||
|
<span class="text-xs">ℹ️</span>
|
||||||
|
<span><strong>Voyants BLF (<code class="text-amber-300 font-mono text-[10px]">SUBSCRIBE</code>) :</strong> Ce paramètre ne gère pas les touches BLF. La fréquence de rafraîchissement des touches de supervision est décidée directement par le téléphone lui-même (ex. paramètre Yealink <code class="text-amber-300 font-mono text-[10px]">account.X.blf.subscribe_period = 120</code>).</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 shrink-0">
|
||||||
|
<input type="number" id="cluster-blf-expiry" name="blf_expiry" value="120" min="30" max="3600"
|
||||||
|
class="w-24 px-3 py-2 rounded-xl bg-anthracite-900 border border-anthracite-700 text-sm text-white text-center font-mono focus:outline-none focus:border-cyan-400">
|
||||||
|
<span class="text-xs text-slate-400 font-mono">sec</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- OPTIONS AVANCÉES (VRID, AUTO-SYNC) -->
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 pt-2 border-t border-anthracite-700/60">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input type="checkbox" id="cluster-auto-sync" name="auto_sync" value="1" checked
|
||||||
|
class="rounded bg-anthracite-900 border-anthracite-700 text-cyan-500 focus:ring-cyan-400">
|
||||||
|
<label for="cluster-auto-sync" class="text-xs text-slate-300 cursor-pointer">Synchroniser automatiquement la base et les sons vers le pair</label>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-end gap-2 text-xs text-slate-400 font-mono">
|
||||||
|
<span>Virtual Router ID (VRID) :</span>
|
||||||
|
<input type="number" id="cluster-vrid" name="vrid" value="51" min="1" max="255"
|
||||||
|
class="w-16 px-2 py-1 rounded bg-anthracite-900 border border-anthracite-700 text-white text-center text-xs">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pt-2 flex justify-end">
|
||||||
|
<button type="submit"
|
||||||
|
class="px-5 py-2.5 rounded-xl font-bold text-xs bg-cyan-500 hover:bg-cyan-400 text-slate-950 transition flex items-center gap-2 shadow-lg shadow-cyan-500/20">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
|
||||||
|
<span>Enregistrer & Déployer le Cluster Keepalived</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- PANNEAU DROIT : LOGS & DIAGNOSTIC EN DIRECT (1/3) -->
|
||||||
|
<div class="bg-anthracite-800 p-6 rounded-2xl border border-anthracite-700 space-y-4 flex flex-col justify-between">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<h3 class="text-sm font-bold text-white uppercase tracking-wider flex items-center gap-2">
|
||||||
|
<svg class="w-4 h-4 text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||||
|
<span>Services & Santé Système</span>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="space-y-2 text-xs">
|
||||||
|
<div class="flex items-center justify-between p-2.5 rounded-xl bg-anthracite-900 border border-anthracite-700/80">
|
||||||
|
<span class="text-slate-300">Démon Keepalived</span>
|
||||||
|
<span id="cluster-svc-keepalived" class="font-mono text-slate-400">Vérification...</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between p-2.5 rounded-xl bg-anthracite-900 border border-anthracite-700/80">
|
||||||
|
<span class="text-slate-300">Processus Asterisk</span>
|
||||||
|
<span id="cluster-svc-asterisk" class="font-mono text-slate-400">Vérification...</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between p-2.5 rounded-xl bg-anthracite-900 border border-anthracite-700/80">
|
||||||
|
<span class="text-slate-300">Ping Nœud Pair</span>
|
||||||
|
<span id="cluster-svc-peer" class="font-mono text-slate-400">—</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pt-2">
|
||||||
|
<h4 class="text-xs font-bold text-slate-300 uppercase mb-1.5 flex items-center justify-between">
|
||||||
|
<span>Journal d'Événements Cluster</span>
|
||||||
|
<span class="text-[10px] text-slate-500 font-mono">/var/log/nexus_cluster.log</span>
|
||||||
|
</h4>
|
||||||
|
<div id="cluster-logs-container"
|
||||||
|
class="h-44 overflow-y-auto p-3 rounded-xl bg-anthracite-950 border border-anthracite-700/80 font-mono text-[11px] text-slate-300 space-y-1">
|
||||||
|
<p class="text-slate-500">Aucun événement enregistré.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-3 rounded-xl bg-indigo-950/40 border border-indigo-500/30 text-[11px] text-indigo-300">
|
||||||
|
💡 <strong>Conseil d'Architecture :</strong> Vos téléphones IP (Yealink, Groundwire) et vos Trunks SIP doivent pointer sur l'adresse <strong>IP Virtuelle (VIP)</strong>.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -5074,6 +5374,90 @@ $appVersion = $appVerData['version'] ?? '1.5.0';
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ===================================================================== -->
|
||||||
|
<!-- MODAL : CONFIRMATION DE REDÉMARRAGE POSTE SIP -->
|
||||||
|
<!-- ===================================================================== -->
|
||||||
|
<div id="modal-confirm-reboot-extension" class="fixed inset-0 z-50 hidden items-center justify-center bg-black/80 backdrop-blur-sm p-3 sm:p-4 overflow-y-auto">
|
||||||
|
<div class="bg-anthracite-900 border border-amber-500/40 rounded-2xl w-full max-w-lg shadow-2xl overflow-hidden my-6 animate-fadeIn">
|
||||||
|
<!-- Entête -->
|
||||||
|
<div class="px-6 py-5 border-b border-anthracite-800 bg-gradient-to-r from-amber-950/40 via-anthracite-900 to-anthracite-900 flex items-start justify-between">
|
||||||
|
<div class="flex items-center gap-3.5">
|
||||||
|
<div class="w-11 h-11 rounded-xl bg-amber-500/15 border border-amber-500/30 text-amber-400 flex items-center justify-center shrink-0 shadow-lg shadow-amber-950/50">
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<h3 class="text-base font-bold text-white">Redémarrer le Téléphone IP</h3>
|
||||||
|
<span class="px-2 py-0.5 rounded text-[10px] font-bold tracking-wide uppercase bg-amber-500/20 text-amber-300 border border-amber-500/30">SIP NOTIFY</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-slate-400 mt-0.5">
|
||||||
|
Poste <span id="reboot-modal-ext-num" class="font-mono font-bold text-indigo-300"></span> — <span id="reboot-modal-ext-name" class="font-medium text-slate-200"></span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" onclick="closeRebootModal()" class="text-slate-400 hover:text-white p-1.5 rounded-lg hover:bg-anthracite-800 transition">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Corps -->
|
||||||
|
<div class="p-6 space-y-4">
|
||||||
|
<!-- Indicateur de vérification de l'état en direct -->
|
||||||
|
<div id="reboot-modal-checking" class="flex items-center gap-2.5 p-3 rounded-xl bg-anthracite-950 border border-anthracite-800 text-xs text-slate-400">
|
||||||
|
<svg class="w-4 h-4 text-indigo-400 animate-spin shrink-0" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z"></path></svg>
|
||||||
|
<span>Vérification des appels en cours sur ce combiné...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ALERTE ROUGE : Appel / conversation en cours -->
|
||||||
|
<div id="reboot-modal-call-warning" class="hidden p-4 rounded-xl bg-rose-500/15 border-2 border-rose-500/60 space-y-3">
|
||||||
|
<div class="flex items-center gap-2 text-rose-400 font-bold text-sm">
|
||||||
|
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/></svg>
|
||||||
|
<span>ATTENTION : CONVERSATION EN COURS SUR CE POSTE !</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-rose-200">
|
||||||
|
Ce combiné est actuellement <strong class="underline">en communication active</strong>.
|
||||||
|
</p>
|
||||||
|
<div id="reboot-modal-call-details" class="p-2.5 rounded-lg bg-black/50 border border-rose-500/40 text-xs font-mono text-rose-300"></div>
|
||||||
|
<div class="p-3 rounded-lg bg-rose-950/60 border border-rose-500/40 text-xs font-semibold text-rose-200 flex items-start gap-2">
|
||||||
|
<span class="text-base leading-none">⚠️</span>
|
||||||
|
<span>Si vous confirmez le redémarrage, <strong>la conversation téléphonique sera immédiatement coupée</strong> pour votre interlocuteur !</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Information normale (quand pas d'appel) -->
|
||||||
|
<div id="reboot-modal-normal-info" class="hidden space-y-3">
|
||||||
|
<div class="p-3.5 rounded-xl bg-anthracite-950 border border-anthracite-800 text-xs text-slate-300 space-y-2">
|
||||||
|
<div class="flex items-center gap-2 text-emerald-400 font-semibold">
|
||||||
|
<span class="w-2 h-2 rounded-full bg-emerald-400"></span>
|
||||||
|
<span>Aucune conversation en cours (poste libre)</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-slate-400">
|
||||||
|
Un ordre SIP NOTIFY standard (<code class="text-amber-300 font-mono">check-sync;reboot=true</code>) va être envoyé au téléphone.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-3.5 rounded-xl bg-amber-500/10 border border-amber-500/20 text-xs text-amber-200 space-y-1">
|
||||||
|
<div class="font-bold">Effet du redémarrage :</div>
|
||||||
|
<p class="text-slate-300">Le téléphone va redémarrer, recharger son provisioning et synchroniser ses touches BLF (interruption d'environ 30 à 45 secondes).</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Boutons d'action -->
|
||||||
|
<div class="px-6 py-4 border-t border-anthracite-800 bg-anthracite-950/80 flex items-center justify-end gap-3">
|
||||||
|
<button type="button" onclick="closeRebootModal()"
|
||||||
|
class="px-4 py-2.5 rounded-xl text-xs font-semibold bg-anthracite-800 hover:bg-anthracite-750 text-slate-300 border border-anthracite-700 transition">
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button type="button" id="btn-confirm-reboot-action" onclick="confirmRebootExtension()"
|
||||||
|
class="px-5 py-2.5 rounded-xl text-xs font-bold bg-amber-600 hover:bg-amber-500 text-white shadow-lg shadow-amber-600/30 transition flex items-center gap-2">
|
||||||
|
<span id="reboot-confirm-btn-label">Confirmer le redémarrage</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script src="/assets/app.js?v=<?= filemtime(__DIR__ . '/assets/app.js') ?>"></script>
|
<script src="/assets/app.js?v=<?= filemtime(__DIR__ . '/assets/app.js') ?>"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
Executable
+24
@@ -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
|
||||||
Executable
+44
@@ -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
|
||||||
Executable
+25
@@ -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
|
||||||
Executable
+90
@@ -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
|
||||||
Executable
+142
@@ -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
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
+43
-4
@@ -102,26 +102,65 @@ chmod 2775 "$APP_DIR/storage" "$APP_DIR/storage/backups" "$APP_DIR/storage/sound
|
|||||||
mkdir -p /etc/letsencrypt /var/log/letsencrypt /var/lib/letsencrypt 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
|
chgrp -R asterisk /etc/letsencrypt 2>/dev/null || true
|
||||||
chmod -R g+rX /etc/letsencrypt 2>/dev/null || true
|
chmod -R g+rX /etc/letsencrypt 2>/dev/null || true
|
||||||
chmod +x "$APP_DIR/scripts/update.sh" "$APP_DIR/scripts/nexus-firewall" "$APP_DIR/scripts/nexus-banner.sh" 2>/dev/null || true
|
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, /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
|
||||||
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 || {
|
which ffmpeg >/dev/null 2>&1 && which rsync >/dev/null 2>&1 && which sox >/dev/null 2>&1 && which sshpass >/dev/null 2>&1 && which keepalived >/dev/null 2>&1 && which arping >/dev/null 2>&1 || {
|
||||||
echo -e " -> Installation de paquets complémentaires..."
|
echo -e " -> Installation de paquets complémentaires..."
|
||||||
(apt-get update -y -qq && apt-get install -y -qq --no-install-recommends acl ffmpeg rsync sox sshpass) 2>/dev/null || true
|
(apt-get update -y -qq && apt-get install -y -qq --no-install-recommends acl ffmpeg rsync sox sshpass keepalived iputils-arping) 2>/dev/null || true
|
||||||
}
|
}
|
||||||
|
|
||||||
# 4. Exécution des migrations SQLite et régénération Dialplan
|
# 4. Exécution des migrations SQLite et régénération Dialplan
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
+151
-24
@@ -15,8 +15,9 @@ class ConfigGenerator
|
|||||||
public const MOH_CONF_PATH = '/etc/asterisk/musiconhold.custom.conf';
|
public const MOH_CONF_PATH = '/etc/asterisk/musiconhold.custom.conf';
|
||||||
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 BACKUP_DIR = __DIR__ . '/../storage/backups';
|
public const PJSIP_NOTIFY_CONF_PATH = '/etc/asterisk/pjsip_notify.conf';
|
||||||
|
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,9 +87,13 @@ class ConfigGenerator
|
|||||||
$lines[] = "; ===========================================================================";
|
$lines[] = "; ===========================================================================";
|
||||||
$lines[] = "";
|
$lines[] = "";
|
||||||
|
|
||||||
$rtpTimeout = max(5, (int)($settings['rtp_timeout'] ?? 30));
|
// 0. Paramètres globaux PJSIP (Keepalive applicatif TLS/TCP toutes les 30s pour préserver les sessions NAT hors réseau)
|
||||||
$rtpHoldTimeout = max(0, (int)($settings['rtp_timeout_hold'] ?? 0));
|
$lines[] = "; --- PARAMÈTRES GLOBAUX PJSIP (KEEPALIVE NAT / HORS RÉSEAU) ---";
|
||||||
$rtpKeepalive = max(0, (int)($settings['rtp_keepalive'] ?? 15));
|
$lines[] = "[global]";
|
||||||
|
$lines[] = "type = global";
|
||||||
|
$lines[] = "keep_alive_interval = 30";
|
||||||
|
$lines[] = "contact_expiration_check_interval = 15";
|
||||||
|
$lines[] = "";
|
||||||
|
|
||||||
$tcpKeepaliveLines = [
|
$tcpKeepaliveLines = [
|
||||||
"tcp_keepalive_enable = yes",
|
"tcp_keepalive_enable = yes",
|
||||||
@@ -78,20 +102,12 @@ class ConfigGenerator
|
|||||||
"tcp_keepalive_probe_count = 3",
|
"tcp_keepalive_probe_count = 3",
|
||||||
];
|
];
|
||||||
|
|
||||||
// 0. Paramètres globaux PJSIP (Keepalive applicatif TLS/TCP toutes les 30s pour postes hors réseau / NAT)
|
|
||||||
$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[] = "";
|
|
||||||
|
|
||||||
// 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);
|
||||||
@@ -105,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[] = "";
|
||||||
@@ -114,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);
|
||||||
@@ -128,6 +147,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}";
|
||||||
}
|
}
|
||||||
array_push($lines, ...$tcpKeepaliveLines);
|
array_push($lines, ...$tcpKeepaliveLines);
|
||||||
$lines[] = "allow_reload = no";
|
$lines[] = "allow_reload = no";
|
||||||
@@ -172,7 +194,7 @@ 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}";
|
||||||
@@ -199,6 +221,9 @@ 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);
|
array_push($lines, ...$tcpKeepaliveLines);
|
||||||
$lines[] = "allow_reload = no";
|
$lines[] = "allow_reload = no";
|
||||||
@@ -208,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']));
|
||||||
@@ -242,7 +279,11 @@ 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 (coupure réseau, NAT hors réseau)
|
|
||||||
|
// 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 = {$rtpTimeout}";
|
||||||
$lines[] = "rtp_timeout_hold = {$rtpHoldTimeout}";
|
$lines[] = "rtp_timeout_hold = {$rtpHoldTimeout}";
|
||||||
if ($rtpKeepalive > 0) {
|
if ($rtpKeepalive > 0) {
|
||||||
@@ -291,9 +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 = 120";
|
$lines[] = "default_expiration = {$regExpiration}";
|
||||||
$lines[] = "maximum_expiration = 300";
|
$lines[] = "maximum_expiration = {$regMaxExpiration}";
|
||||||
$lines[] = "minimum_expiration = 60";
|
$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";
|
||||||
@@ -404,6 +445,8 @@ class ConfigGenerator
|
|||||||
$lines[] = "trust_id_inbound = {$trustInbound}";
|
$lines[] = "trust_id_inbound = {$trustInbound}";
|
||||||
$lines[] = "trust_id_outbound = {$trustOutbound}";
|
$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 = {$rtpTimeout}";
|
||||||
$lines[] = "rtp_timeout_hold = {$rtpHoldTimeout}";
|
$lines[] = "rtp_timeout_hold = {$rtpHoldTimeout}";
|
||||||
if ($rtpKeepalive > 0) {
|
if ($rtpKeepalive > 0) {
|
||||||
@@ -1643,6 +1686,30 @@ 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";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1781,9 +1848,10 @@ class ConfigGenerator
|
|||||||
$rtpEnd = max($rtpStart + 4, min(65535, (int)($settings['rtp_end'] ?? 20000)));
|
$rtpEnd = max($rtpStart + 4, min(65535, (int)($settings['rtp_end'] ?? 20000)));
|
||||||
$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));
|
||||||
$keepalive = max(0, (int)($settings['rtp_keepalive'] ?? 15));
|
$holdTimeout = max(0, (int)($settings['rtp_timeout_hold'] ?? 0));
|
||||||
$date = date('Y-m-d H:i:s');
|
$keepalive = max(0, (int)($settings['rtp_keepalive'] ?? 15));
|
||||||
|
$date = date('Y-m-d H:i:s');
|
||||||
|
|
||||||
$lines = [];
|
$lines = [];
|
||||||
$lines[] = "; ===========================================================================";
|
$lines[] = "; ===========================================================================";
|
||||||
@@ -1796,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}";
|
||||||
}
|
}
|
||||||
@@ -1847,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) {
|
||||||
@@ -1889,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)) {
|
||||||
@@ -1901,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);
|
||||||
@@ -1908,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();
|
||||||
@@ -2236,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)";
|
||||||
|
|||||||
+88
-4
@@ -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,17 @@ 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 rtp_timeout_hold FROM settings LIMIT 1");
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
self::initializeSchema();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,6 +89,7 @@ class Database
|
|||||||
rtp_checksums INTEGER NOT NULL DEFAULT 1,
|
rtp_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,
|
||||||
@@ -307,6 +315,29 @@ 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
|
||||||
|
);
|
||||||
SQL;
|
SQL;
|
||||||
|
|
||||||
$db->exec($schema);
|
$db->exec($schema);
|
||||||
@@ -322,6 +353,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();
|
||||||
@@ -361,6 +393,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'",
|
||||||
@@ -749,8 +782,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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
+13
-15
@@ -1,20 +1,18 @@
|
|||||||
{
|
{
|
||||||
"version": "1.6.6",
|
"version": "2.0.0",
|
||||||
"codename": "Helios",
|
"codename": "Pulsar",
|
||||||
"release_date": "2026-11-10",
|
"release_date": "2026-11-11",
|
||||||
"min_php_version": "8.2",
|
"min_php_version": "8.2",
|
||||||
"changelog": [
|
"changelog": [
|
||||||
"PJSIP Endpoints & Trunks : injection effective de rtp_timeout, rtp_timeout_hold, rtp_keepalive et SIP Session Timers (RFC 4028) sur chaque endpoint PJSIP pour raccrocher instantanément les canaux fantômes",
|
"NexusPBX 2.0 « Pulsar » : Version Majeure Haute Disponibilité Télécom & Résilience",
|
||||||
"PJSIP Global & Transports TLS/TCP : keepalive applicatif réduit à 30s (keep_alive_interval = 30), vérification expiration contacts toutes les 15s et TCP keepalives noyau (20s/5s/3) pour fiabiliser les BLF et postes hors réseau derrière NAT",
|
"Haute Disponibilité (HA) : cluster actif/passif avec IP Virtuelle flottante (VIP) et consensus Keepalived VRRP",
|
||||||
"Trunks SIP : dissociation stricte du CallerID sortant du Trunk (suppression de callerid dans l'endpoint PJSIP pour préserver l'affichage du numéro appelant réel sur les appels entrants)",
|
"HA Télécom : liaison stricte des transports PJSIP sur la VIP et annonce exclusive de la VIP en signalisation/média",
|
||||||
"Trunks SIP : suppression du forçage de from_user afin de préserver l'en-tête SIP From dynamique avec Nom d'appelant (CNAM) pour OVH / Orange",
|
"HA Télécom : isolation SIP stricte (iptables REJECT) sur nœud Standby et purge de subscription_persistence AstDB éliminant tout dialogue BLF zombie au failover",
|
||||||
"Trunks SIP & Routage Sortant : activation systématique de trust_id_outbound et formatage strict du Caller ID Name (CNAM) pour affichage garanti chez les opérateurs distants (OVH, Orange, etc.)",
|
"HA Télécom : réveil BLF progressif automatique (check-sync) dès réenregistrement des postes sur le nouveau Master",
|
||||||
"Sauvegarde & Restauration : versionnage dynamique automatique basé sur la version installée (résolution du tag figé v1.2.0)",
|
"HA Données & Sécurité : réplication atomique SQLite (VACUUM INTO), synchro miroir voicemails & sons personnalisés (rsync --delete), propagation des IP bannies Fail2ban et réconciliation automatique au failback (--failback-check)",
|
||||||
"Mises à jour système OTA : déclenchement garanti d'une sauvegarde Disaster Recovery complète pré-mise à jour (visible et restaurable dans l'interface)",
|
"Postes Hors Réseau & NAT/TLS : keepalive applicatif 30s (keep_alive_interval), TCP keepalives noyau (20s/5s/3), rtp_timeout, rtp_keepalive et SIP Session Timers (RFC 4028) sur tous les endpoints et trunks",
|
||||||
"Interface Sauvegardes : nouveau badge identifiant la source (Pré-MàJ, Auto, Manuel)",
|
"Routage & Groupes d'Appel : nouvelle destination directe Répondeur (vm-<poste>) en débordement, SVI et routes entrantes",
|
||||||
"Identité visuelle officielle : intégration du nouveau logo NexusPBX 3D haute définition épuré et du favicon",
|
"Sécurité & Téléphonie : détection temps réel des conversations actives avant redémarrage de poste avec confirmation sécurisée",
|
||||||
"Nouveau slogan télécom : « Supervision & Téléphonie IP • Powered by Asterisk »",
|
"Paramètres SIP : fiabilisation du rtp_timeout_hold et expiration AOR plafonnée (120s-300s) pour maintenir les tunnels NAT actifs"
|
||||||
"Module Journal d'Appels & CDR avec filtres et export CSV",
|
|
||||||
"Support universel TLSv1.2/TLSv1.3 et suppression CertificateRequest pour postes Yealink"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user