feat(cluster): add configurable BLF expiry interval in HA menu with 120s default and automatic check-sync notify on failover

This commit is contained in:
sorcier committed 2026-11-06 01:20:16 +01:00
1 parent c1c6eab03c
commit f14185d597
6 files changed
+145 -17

No files matched your search

+28
View File
@@ -6394,6 +6394,33 @@ async function refreshClusterStatus() {
const secretInput = document.getElementById('cluster-secret'); const secretInput = document.getElementById('cluster-secret');
if (secretInput && st.cluster_secret) secretInput.value = st.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 // Badges & Cartes
const headerBadge = document.getElementById('cluster-header-state-badge'); const headerBadge = document.getElementById('cluster-header-state-badge');
const navBadge = document.getElementById('nav-badge-cluster'); const navBadge = document.getElementById('nav-badge-cluster');
@@ -6584,6 +6611,7 @@ async function saveClusterSettings(e) {
peer_port: parseInt(form.elements['peer_port'] ? form.elements['peer_port'].value : 8443, 10) || 8443, peer_port: parseInt(form.elements['peer_port'] ? form.elements['peer_port'].value : 8443, 10) || 8443,
cluster_secret: form.elements['cluster_secret'].value.trim(), cluster_secret: form.elements['cluster_secret'].value.trim(),
vrid: parseInt(form.elements['vrid'].value, 10) || 51, 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 auto_sync: form.elements['auto_sync'].checked ? 1 : 0
}; };
+18
View File
@@ -2939,6 +2939,24 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
</div> </div>
</div> </div>
<!-- DÉLAI D'EXPIRATION / RÉABONNEMENT BLF (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>
<div class="flex items-center gap-2 mb-0.5">
<label for="cluster-blf-expiry" class="text-xs font-bold text-white cursor-pointer">Fréquence de réabonnement BLF &amp; Postes</label>
<span class="px-2 py-0.2 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">Plafond d'expiration imposé aux téléphones pour forcer le rafraîchissement rapide des voyants BLF après bascule HA (par défaut 1800s sans HA, 120s avec HA).</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) --> <!-- 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="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"> <div class="flex items-center gap-2">
+9
View File
@@ -55,5 +55,14 @@ if [ -n "$VIP_CLEAN" ] && command -v arping >/dev/null 2>&1; then
arping -c 5 -U -I "$IFACE" "$VIP_CLEAN" >> "$LOG_FILE" 2>&1 || true arping -c 5 -U -I "$IFACE" "$VIP_CLEAN" >> "$LOG_FILE" 2>&1 || true
fi fi
# 5. Envoi automatique du signal check-sync silencieux aux téléphones enregistrés
# Force Yealink, Fanvil, Snom à réémettre instantanément tous leurs SUBSCRIBE BLF sur ce serveur actif
if pidof asterisk >/dev/null 2>&1; then
echo "[$TIMESTAMP] [CLUSTER] Envoi check-sync de réveil aux postes pour réabonnement immédiat des BLF..." >> "$LOG_FILE"
for ep in $(/usr/sbin/asterisk -rx "pjsip show endpoints" 2>/dev/null | grep -o 'Endpoint: [0-9]*' | awk '{print $2}'); do
/usr/sbin/asterisk -rx "pjsip send notify check-sync endpoint $ep" >> "$LOG_FILE" 2>&1 || true
done
fi
echo "[$TIMESTAMP] [CLUSTER] Prise de relais MASTER terminée avec succès." >> "$LOG_FILE" echo "[$TIMESTAMP] [CLUSTER] Prise de relais MASTER terminée avec succès." >> "$LOG_FILE"
exit 0 exit 0
+31 -12
View File
@@ -42,6 +42,7 @@ class ClusterManager
'cluster_secret' => bin2hex(random_bytes(16)), 'cluster_secret' => bin2hex(random_bytes(16)),
'vrid' => 51, 'vrid' => 51,
'priority' => 100, 'priority' => 100,
'blf_expiry' => 120,
'sync_interval' => 15, 'sync_interval' => 15,
'auto_sync' => 1, 'auto_sync' => 1,
'last_sync_at' => null, 'last_sync_at' => null,
@@ -52,6 +53,9 @@ class ClusterManager
if (empty($row['peer_port'])) { if (empty($row['peer_port'])) {
$row['peer_port'] = 8443; $row['peer_port'] = 8443;
} }
if (empty($row['blf_expiry'])) {
$row['blf_expiry'] = 120;
}
return $row; return $row;
} }
@@ -70,6 +74,7 @@ class ClusterManager
$clusterSecret = trim((string)($data['cluster_secret'] ?? '')); $clusterSecret = trim((string)($data['cluster_secret'] ?? ''));
$vrid = max(1, min(255, (int)($data['vrid'] ?? 51))); $vrid = max(1, min(255, (int)($data['vrid'] ?? 51)));
$priority = max(1, min(254, (int)($data['priority'] ?? ($role === 'master' ? 100 : 90)))); $priority = max(1, min(254, (int)($data['priority'] ?? ($role === 'master' ? 100 : 90))));
$blfExpiry = max(30, min(3600, (int)($data['blf_expiry'] ?? 120)));
$syncInterval = max(5, min(3600, (int)($data['sync_interval'] ?? 15))); $syncInterval = max(5, min(3600, (int)($data['sync_interval'] ?? 15)));
$autoSync = !empty($data['auto_sync']) ? 1 : 0; $autoSync = !empty($data['auto_sync']) ? 1 : 0;
@@ -130,6 +135,7 @@ class ClusterManager
cluster_secret = :secret, cluster_secret = :secret,
vrid = :vrid, vrid = :vrid,
priority = :prio, priority = :prio,
blf_expiry = :blf_expiry,
sync_interval = :sync_int, sync_interval = :sync_int,
auto_sync = :auto_sync, auto_sync = :auto_sync,
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
@@ -137,23 +143,34 @@ class ClusterManager
'); ');
$stmt->execute([ $stmt->execute([
':enabled' => $enabled, ':enabled' => $enabled,
':role' => $role, ':role' => $role,
':vip' => $virtualIp, ':vip' => $virtualIp,
':vmask' => $virtualNetmask, ':vmask' => $virtualNetmask,
':iface' => $interface, ':iface' => $interface,
':peer' => $peerIp, ':peer' => $peerIp,
':peer_port' => $peerPort, ':peer_port' => $peerPort,
':secret' => $clusterSecret, ':secret' => $clusterSecret,
':vrid' => $vrid, ':vrid' => $vrid,
':prio' => $priority, ':prio' => $priority,
':sync_int' => $syncInterval, ':blf_expiry' => $blfExpiry,
':auto_sync' => $autoSync, ':sync_int' => $syncInterval,
':auto_sync' => $autoSync,
]); ]);
// Application automatique de la configuration Keepalived // Application automatique de la configuration Keepalived
$applyResult = $this->applyKeepalivedConfig(); $applyResult = $this->applyKeepalivedConfig();
// Régénération de la configuration PJSIP et rechargement Asterisk avec le nouveau délai d'abonnement
try {
$gen = new ConfigGenerator($this->db);
$gen->writeAllConfigs();
shell_exec('asterisk -rx "pjsip reload" 2>/dev/null || true');
shell_exec('asterisk -rx "dialplan reload" 2>/dev/null || true');
} catch (\Throwable $e) {
error_log("[CLUSTER] Erreur régénération configs Asterisk : " . $e->getMessage());
}
return [ return [
'success' => true, 'success' => true,
'message' => 'Configuration Haute Disponibilité enregistrée et appliquée.', 'message' => 'Configuration Haute Disponibilité enregistrée et appliquée.',
@@ -723,6 +740,7 @@ class ClusterManager
virtual_ip = :virtual_ip, virtual_ip = :virtual_ip,
virtual_netmask = :virtual_netmask, virtual_netmask = :virtual_netmask,
vrid = :vrid, vrid = :vrid,
blf_expiry = :blf_expiry,
last_sync_at = CURRENT_TIMESTAMP, last_sync_at = CURRENT_TIMESTAMP,
last_sync_status = :status, last_sync_status = :status,
last_sync_message = :msg last_sync_message = :msg
@@ -737,6 +755,7 @@ class ClusterManager
':virtual_ip' => $localClusterSettings['virtual_ip'] ?? '', ':virtual_ip' => $localClusterSettings['virtual_ip'] ?? '',
':virtual_netmask' => $localClusterSettings['virtual_netmask'] ?? '24', ':virtual_netmask' => $localClusterSettings['virtual_netmask'] ?? '24',
':vrid' => $localClusterSettings['vrid'] ?? 51, ':vrid' => $localClusterSettings['vrid'] ?? 51,
':blf_expiry' => (int)($localClusterSettings['blf_expiry'] ?? 120),
':status' => 'synced', ':status' => 'synced',
':msg' => 'Base et sons synchronisés depuis le Master', ':msg' => 'Base et sons synchronisés depuis le Master',
]); ]);
+53 -5
View File
@@ -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) {}
@@ -187,6 +188,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 Haute Disponibilité, on plafonne l'expiration des AOR/souscriptions sur la valeur configurée (par défaut 120s)
// afin que les téléphones se réabonnent très vite en cas de bascule. Sans HA, on conserve le standard 1800s (30 min).
$regExpiration = $isHaActive ? max(30, min(3600, (int)($clusterSt['blf_expiry'] ?? 120))) : 1800;
$subMinExpiry = $isHaActive ? 30 : 0;
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']));
@@ -274,9 +287,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 = {$regExpiration}";
$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";
@@ -1874,6 +1887,39 @@ 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
[yealink-check-cfg]
Event=>check-sync\;reboot=false
[snom-check-cfg]
Event=>check-sync\;reboot=false
[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)) {
@@ -1886,6 +1932,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);
@@ -1893,6 +1940,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();
+6
View File
@@ -329,6 +329,7 @@ class Database
cluster_secret VARCHAR(64) NOT NULL DEFAULT '', cluster_secret VARCHAR(64) NOT NULL DEFAULT '',
vrid INTEGER NOT NULL DEFAULT 51, vrid INTEGER NOT NULL DEFAULT 51,
priority INTEGER NOT NULL DEFAULT 100, priority INTEGER NOT NULL DEFAULT 100,
blf_expiry INTEGER NOT NULL DEFAULT 120,
sync_interval INTEGER NOT NULL DEFAULT 15, sync_interval INTEGER NOT NULL DEFAULT 15,
auto_sync INTEGER NOT NULL DEFAULT 1, auto_sync INTEGER NOT NULL DEFAULT 1,
last_sync_at DATETIME DEFAULT NULL, last_sync_at DATETIME DEFAULT NULL,
@@ -813,6 +814,11 @@ class Database
$db->exec("ALTER TABLE cluster_settings ADD COLUMN peer_port INTEGER NOT NULL DEFAULT 8443"); $db->exec("ALTER TABLE cluster_settings ADD COLUMN peer_port INTEGER NOT NULL DEFAULT 8443");
} catch (\Throwable $e) {} } 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(); $count = (int)$db->query('SELECT COUNT(*) FROM cluster_settings')->fetchColumn();
if ($count === 0) { if ($count === 0) {
$defaultSecret = bin2hex(random_bytes(16)); $defaultSecret = bin2hex(random_bytes(16));