Compare commits
28
Commits
8efeec66ca
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bfc71a7763 | ||
|
|
f99cf440a7 | ||
|
|
5cb3900c67 | ||
|
|
64b8339d14 | ||
|
|
ba04af32bb | ||
|
|
ddbaabb657 | ||
|
|
70a021ef63 | ||
|
|
b8cf932651 | ||
|
|
871a90c102 | ||
|
|
f94299def1 | ||
|
|
d9f1470c64 | ||
|
|
5fddf3f275 | ||
|
|
b702e369a8 | ||
|
|
c980aa2904 | ||
|
|
f918c929f5 | ||
|
|
8cb8f1cdf7 | ||
|
|
745e9307d7 | ||
|
|
789ea79cf2 | ||
|
|
28efb2d6bc | ||
|
|
135a6267db | ||
|
|
26cac163d6 | ||
|
|
d8ec7e4508 | ||
|
|
c10c980024 | ||
|
|
d618d426e7 | ||
|
|
2a7bb2a122 | ||
|
|
8df103d7a0 | ||
|
|
c96af67ac9 | ||
|
|
72e43f1a9a |
No files matched your search
+22
-10
@@ -184,9 +184,9 @@ touch /var/log/asterisk/messages /var/log/asterisk/security.log /var/log/asteris
|
||||
chown -R asterisk:asterisk /var/log/asterisk 2>/dev/null || true
|
||||
chmod 0664 /var/log/asterisk/messages /var/log/asterisk/security.log /var/log/asterisk/cdr-csv/Master.csv 2>/dev/null || true
|
||||
|
||||
touch /var/log/nexus_security.log
|
||||
chown www-data:asterisk /var/log/nexus_security.log 2>/dev/null || true
|
||||
chmod 0664 /var/log/nexus_security.log 2>/dev/null || true
|
||||
touch /var/log/nexus_security.log /var/log/nexus_ucp_security.log
|
||||
chown www-data:asterisk /var/log/nexus_security.log /var/log/nexus_ucp_security.log 2>/dev/null || true
|
||||
chmod 0664 /var/log/nexus_security.log /var/log/nexus_ucp_security.log 2>/dev/null || true
|
||||
|
||||
# Droits applicatifs stricts
|
||||
chown -R www-data:asterisk "$TARGET_DIR"
|
||||
@@ -195,6 +195,7 @@ chmod 2775 "$TARGET_DIR/storage" \
|
||||
"$TARGET_DIR/storage/system_backups" \
|
||||
"$TARGET_DIR/storage/sounds" \
|
||||
"$TARGET_DIR/public/avatars"
|
||||
[ -d "$TARGET_DIR/ucp" ] && chmod 0755 "$TARGET_DIR/ucp" "$TARGET_DIR/ucp/assets" 2>/dev/null || true
|
||||
|
||||
mkdir -p /var/lib/asterisk/sounds/custom /var/lib/asterisk/moh /var/spool/asterisk/voicemail
|
||||
chown -R asterisk:asterisk /etc/asterisk /var/lib/asterisk /var/spool/asterisk /var/log/asterisk
|
||||
@@ -207,7 +208,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)
|
||||
cat << 'EOF' > /etc/sudoers.d/nexus-pbx
|
||||
# Privilèges spécifiques NexusPBX pour l'utilisateur Web www-data
|
||||
www-data ALL=(ALL) NOPASSWD: /usr/sbin/asterisk, /usr/sbin/nginx, /usr/bin/systemctl, /usr/bin/fail2ban-client, /bin/fail2ban-client, /var/www/nexus-pbx/scripts/nexus-firewall, /usr/bin/certbot, /usr/sbin/nft, /usr/sbin/iptables, /var/www/nexus-pbx/scripts/update.sh, /usr/local/bin/nexus-update, /var/www/nexus-pbx/scripts/nexus-cluster
|
||||
www-data ALL=(ALL) NOPASSWD: /usr/sbin/asterisk, /usr/sbin/nginx, /usr/bin/systemctl, /usr/bin/fail2ban-client, /bin/fail2ban-client, /var/www/nexus-pbx/scripts/nexus-firewall, /usr/bin/certbot, /usr/sbin/nft, /usr/sbin/iptables, /usr/sbin/ufw, /var/www/nexus-pbx/scripts/update.sh, /usr/local/bin/nexus-update, /var/www/nexus-pbx/scripts/nexus-cluster
|
||||
EOF
|
||||
chmod 0440 /etc/sudoers.d/nexus-pbx
|
||||
|
||||
@@ -436,16 +437,22 @@ mkdir -p /etc/nginx/sites-available /etc/nginx/sites-enabled
|
||||
rm -f /etc/nginx/sites-enabled/default
|
||||
ln -sf /etc/nginx/sites-available/nexus-pbx /etc/nginx/sites-enabled/nexus-pbx
|
||||
|
||||
# Application de la configuration Nginx complète (Ports 80, 8080, 8443)
|
||||
# Application de la configuration Nginx complète (Ports Admin 80/8080/8443 + VHosts UCP isolés 8444/8084)
|
||||
php -r '
|
||||
require_once "/var/www/nexus-pbx/src/Database.php";
|
||||
require_once "/var/www/nexus-pbx/src/FirewallManager.php";
|
||||
$db = App\Database::getConnection();
|
||||
$st = $db->query("SELECT admin_https_port, certbot_strict_port80 FROM settings WHERE id = 1")->fetch(PDO::FETCH_ASSOC);
|
||||
App\FirewallManager::applyNginxConfig((int)($st["admin_https_port"] ?? 8443), (int)($st["certbot_strict_port80"] ?? 0));
|
||||
$st = App\FirewallManager::getSettings();
|
||||
App\FirewallManager::ensureUcpFail2banArtifacts();
|
||||
App\FirewallManager::applyNginxConfig(
|
||||
(int)($st["admin_https_port"] ?? 8443),
|
||||
(int)($st["certbot_strict_port80"] ?? 0),
|
||||
(int)($st["ucp_enabled"] ?? 1),
|
||||
(int)($st["ucp_https_port"] ?? 8444),
|
||||
(int)($st["ucp_http_port"] ?? 8084)
|
||||
);
|
||||
'
|
||||
|
||||
# Filtre Fail2ban pour l'interface Nexus-Web
|
||||
# Filtre Fail2ban pour l'interface Nexus-Web et Portail UCP
|
||||
cat << 'EOF' > /etc/fail2ban/filter.d/nexus-web.conf
|
||||
[Definition]
|
||||
failregex = ^.*\[NEXUS-WEB-AUTH-FAIL\]:\s*<HOST>.*$
|
||||
@@ -465,7 +472,12 @@ App\FirewallManager::generateJailLocal(
|
||||
(int)$st["fail2ban_sip_bantime"],
|
||||
(int)$st["fail2ban_web_maxretry"],
|
||||
(int)$st["fail2ban_web_bantime"],
|
||||
(int)$st["admin_https_port"]
|
||||
(int)$st["admin_https_port"],
|
||||
(int)($st["ucp_enabled"] ?? 1),
|
||||
(int)($st["ucp_https_port"] ?? 8444),
|
||||
(int)($st["ucp_http_port"] ?? 8084),
|
||||
(int)($st["fail2ban_ucp_maxretry"] ?? 3),
|
||||
(int)($st["fail2ban_ucp_bantime"] ?? 3600)
|
||||
);
|
||||
'
|
||||
|
||||
|
||||
+101
-25
@@ -13,6 +13,7 @@ require_once __DIR__ . '/../src/FirewallManager.php';
|
||||
require_once __DIR__ . '/../src/PhonebookManager.php';
|
||||
require_once __DIR__ . '/../src/CdrManager.php';
|
||||
require_once __DIR__ . '/../src/ClusterManager.php';
|
||||
require_once __DIR__ . '/../src/UcpManager.php';
|
||||
|
||||
// Autoloader PSR-4 automatique pour toutes les classes du namespace App
|
||||
spl_autoload_register(function (string $class): void {
|
||||
@@ -36,6 +37,7 @@ use App\FirewallManager;
|
||||
use App\PhonebookManager;
|
||||
use App\CdrManager;
|
||||
use App\ClusterManager;
|
||||
use App\UcpManager;
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Cache-Control: no-store, no-cache, must-revalidate');
|
||||
@@ -96,10 +98,7 @@ try {
|
||||
]);
|
||||
} else {
|
||||
// Journalisation de sécurité pour Fail2ban (jail nexus-web)
|
||||
$clientIp = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1';
|
||||
if (str_contains($clientIp, ',')) {
|
||||
$clientIp = trim(explode(',', $clientIp)[0]);
|
||||
}
|
||||
$clientIp = FirewallManager::resolveClientIp();
|
||||
$logMsg = date('[Y-m-d H:i:s] ') . "[NEXUS-WEB-AUTH-FAIL]: " . $clientIp . " (username: " . ($username ?: 'vide') . ")\n";
|
||||
@file_put_contents('/var/log/nexus_security.log', $logMsg, FILE_APPEND | LOCK_EX);
|
||||
|
||||
@@ -134,6 +133,9 @@ try {
|
||||
|
||||
switch ($action) {
|
||||
case 'get_state':
|
||||
try {
|
||||
FirewallManager::ensureUcpDeployed();
|
||||
} catch (\Throwable) {}
|
||||
$settings = $db->query('SELECT * FROM settings WHERE id = 1')->fetch();
|
||||
$extensions = $db->query('SELECT * FROM extensions ORDER BY CAST(extension AS INTEGER) ASC, extension ASC')->fetchAll();
|
||||
$groups = $db->query('SELECT * FROM groups ORDER BY CAST(extension AS INTEGER) ASC, extension ASC')->fetchAll();
|
||||
@@ -210,6 +212,7 @@ try {
|
||||
'backups_count' => count(BackupManager::listBackups($db)),
|
||||
'phonebook_contacts' => PhonebookManager::getContacts($db),
|
||||
'phonebook_groups' => PhonebookManager::getGroups($db),
|
||||
'ucp_users' => UcpManager::listUsers($db),
|
||||
'metrics' => SystemMetrics::collect($db),
|
||||
'previews' => [
|
||||
'pjsip' => $generator->generatePjsipConf(),
|
||||
@@ -273,6 +276,13 @@ try {
|
||||
@chgrp($webLog, 'asterisk');
|
||||
@chmod($webLog, 0664);
|
||||
}
|
||||
$ucpLog = FirewallManager::UCP_SECURITY_LOG;
|
||||
if (file_exists($ucpLog)) {
|
||||
@file_put_contents($ucpLog, '');
|
||||
@chown($ucpLog, 'www-data');
|
||||
@chgrp($ucpLog, 'asterisk');
|
||||
@chmod($ucpLog, 0664);
|
||||
}
|
||||
@unlink('/tmp/nexus_fw_status_cache.json');
|
||||
exec('sudo /usr/bin/systemctl restart fail2ban 2>&1 || sudo /usr/bin/fail2ban-client reload 2>&1');
|
||||
|
||||
@@ -301,6 +311,7 @@ try {
|
||||
$voicemail_attach = isset($payload['voicemail_attach']) ? (int)!empty($payload['voicemail_attach']) : 1;
|
||||
$voicemail_delete = isset($payload['voicemail_delete']) ? (int)!empty($payload['voicemail_delete']) : 0;
|
||||
$ring_time = max(5, min(120, (int)($payload['ring_time'] ?? 20)));
|
||||
$groundwire_enabled = !empty($payload['groundwire_enabled']) ? 1 : 0;
|
||||
|
||||
if ($force_pin_change === 1) {
|
||||
$secFile = "/var/spool/asterisk/voicemail/default/{$extension}/secret.conf";
|
||||
@@ -329,7 +340,7 @@ try {
|
||||
encryption = :encryption, codecs = :codecs, max_contacts = :max_contacts, enabled = :enabled,
|
||||
voicemail_enabled = :vme, voicemail_pin = :vmp, force_pin_change = :fpc,
|
||||
voicemail_skip_pin = :vsp, voicemail_email = :vme_mail, voicemail_attach = :vma,
|
||||
voicemail_delete = :vmd, ring_time = :rt
|
||||
voicemail_delete = :vmd, ring_time = :rt, groundwire_enabled = :gwe
|
||||
WHERE id = :id
|
||||
');
|
||||
$stmt->execute([
|
||||
@@ -350,14 +361,15 @@ try {
|
||||
':vma' => $voicemail_attach,
|
||||
':vmd' => $voicemail_delete,
|
||||
':rt' => $ring_time,
|
||||
':gwe' => $groundwire_enabled,
|
||||
]);
|
||||
} else {
|
||||
$stmt = $db->prepare('
|
||||
INSERT INTO extensions (extension, name, secret, transport, encryption, codecs, max_contacts, enabled,
|
||||
voicemail_enabled, voicemail_pin, force_pin_change, voicemail_skip_pin,
|
||||
voicemail_email, voicemail_attach, voicemail_delete, ring_time)
|
||||
voicemail_email, voicemail_attach, voicemail_delete, ring_time, groundwire_enabled)
|
||||
VALUES (:ext, :name, :secret, :transport, :encryption, :codecs, :max_contacts, :enabled,
|
||||
:vme, :vmp, :fpc, :vsp, :vme_mail, :vma, :vmd, :rt)
|
||||
:vme, :vmp, :fpc, :vsp, :vme_mail, :vma, :vmd, :rt, :gwe)
|
||||
ON CONFLICT(extension) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
secret = excluded.secret,
|
||||
@@ -373,7 +385,8 @@ try {
|
||||
voicemail_email = excluded.voicemail_email,
|
||||
voicemail_attach = excluded.voicemail_attach,
|
||||
voicemail_delete = excluded.voicemail_delete,
|
||||
ring_time = excluded.ring_time
|
||||
ring_time = excluded.ring_time,
|
||||
groundwire_enabled = excluded.groundwire_enabled
|
||||
');
|
||||
$stmt->execute([
|
||||
':ext' => $extension,
|
||||
@@ -392,6 +405,7 @@ try {
|
||||
':vma' => $voicemail_attach,
|
||||
':vmd' => $voicemail_delete,
|
||||
':rt' => $ring_time,
|
||||
':gwe' => $groundwire_enabled,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -1788,30 +1802,53 @@ try {
|
||||
case 'save_company_branding':
|
||||
$company_name = trim((string)($payload['company_name'] ?? ''));
|
||||
$company_logo = trim((string)($payload['company_logo'] ?? ''));
|
||||
$ucp_logo = trim((string)($payload['ucp_logo'] ?? ''));
|
||||
|
||||
if ($company_logo !== '' && !preg_match('/^data:image\/(png|jpeg|jpg|webp|svg\+xml|gif);base64,/i', $company_logo)) {
|
||||
throw new InvalidArgumentException("Format d'image invalide (utilisez PNG, SVG, JPG ou WebP).");
|
||||
}
|
||||
if (strlen($company_logo) > 2 * 1024 * 1024) {
|
||||
throw new InvalidArgumentException("Le logo est trop volumineux (max 1.5 Mo).");
|
||||
foreach (['company_logo' => $company_logo, 'ucp_logo' => $ucp_logo] as $fieldKey => $logoVal) {
|
||||
if (!array_key_exists($fieldKey, $payload)) {
|
||||
continue;
|
||||
}
|
||||
if ($logoVal !== '' && !preg_match('/^data:image\/(png|jpeg|jpg|webp|svg\+xml|gif);base64,/i', $logoVal)) {
|
||||
throw new InvalidArgumentException("Format d'image invalide (utilisez PNG, SVG, JPG ou WebP).");
|
||||
}
|
||||
if (strlen($logoVal) > 2 * 1024 * 1024) {
|
||||
throw new InvalidArgumentException("Le logo est trop volumineux (max 1.5 Mo).");
|
||||
}
|
||||
}
|
||||
|
||||
if (array_key_exists('company_name', $payload) && array_key_exists('company_logo', $payload)) {
|
||||
$stmt = $db->prepare('UPDATE settings SET company_name = :cn, company_logo = :cl, updated_at = CURRENT_TIMESTAMP WHERE id = 1');
|
||||
$stmt->execute([':cn' => $company_name, ':cl' => $company_logo]);
|
||||
} elseif (array_key_exists('company_logo', $payload)) {
|
||||
$stmt = $db->prepare('UPDATE settings SET company_logo = :cl, updated_at = CURRENT_TIMESTAMP WHERE id = 1');
|
||||
$stmt->execute([':cl' => $company_logo]);
|
||||
} elseif (array_key_exists('company_name', $payload)) {
|
||||
$stmt = $db->prepare('UPDATE settings SET company_name = :cn, updated_at = CURRENT_TIMESTAMP WHERE id = 1');
|
||||
$stmt->execute([':cn' => $company_name]);
|
||||
$sets = [];
|
||||
$params = [];
|
||||
if (array_key_exists('company_name', $payload)) {
|
||||
$sets[] = 'company_name = :cn';
|
||||
$params[':cn'] = $company_name;
|
||||
}
|
||||
if (array_key_exists('company_logo', $payload)) {
|
||||
$sets[] = 'company_logo = :cl';
|
||||
$params[':cl'] = $company_logo;
|
||||
}
|
||||
if (array_key_exists('ucp_logo', $payload)) {
|
||||
$sets[] = 'ucp_logo = :ul';
|
||||
$params[':ul'] = $ucp_logo;
|
||||
}
|
||||
|
||||
if ($sets) {
|
||||
$sets[] = 'updated_at = CURRENT_TIMESTAMP';
|
||||
$stmt = $db->prepare('UPDATE settings SET ' . implode(', ', $sets) . ' WHERE id = 1');
|
||||
$stmt->execute($params);
|
||||
}
|
||||
|
||||
$msg = 'Logo et identité entreprise mis à jour sur NexusPBX !';
|
||||
if (array_key_exists('ucp_logo', $payload) && !array_key_exists('company_logo', $payload) && !array_key_exists('company_name', $payload)) {
|
||||
$msg = $ucp_logo === ''
|
||||
? 'Logo UCP réinitialisé (hérite du logo principal).'
|
||||
: 'Logo spécifique UCP mis à jour !';
|
||||
} elseif (array_key_exists('company_logo', $payload) && $company_logo === '' && !array_key_exists('company_name', $payload) && !array_key_exists('ucp_logo', $payload)) {
|
||||
$msg = 'Logo entreprise réinitialisé.';
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'message' => $company_logo === '' && !array_key_exists('company_name', $payload)
|
||||
? 'Logo entreprise réinitialisé.'
|
||||
: 'Logo et identité entreprise mis à jour sur NexusPBX !',
|
||||
'message' => $msg,
|
||||
]);
|
||||
break;
|
||||
|
||||
@@ -1913,6 +1950,11 @@ try {
|
||||
$ami = AmiClient::fromDatabase($db);
|
||||
$reloadInfo = $ami->reloadPjsipAndDialplan();
|
||||
|
||||
// S'assurer que le VHost UCP & Fail2ban UCP sont déployés même après un git pull manuel
|
||||
try {
|
||||
FirewallManager::ensureUcpDeployed();
|
||||
} catch (\Throwable) {}
|
||||
|
||||
// Actualisation de l'horodatage de modification en base pour la réconciliation cluster
|
||||
try {
|
||||
$db->exec("UPDATE cluster_settings SET updated_at = CURRENT_TIMESTAMP WHERE id = 1");
|
||||
@@ -2446,6 +2488,39 @@ try {
|
||||
echo json_encode($res);
|
||||
break;
|
||||
|
||||
case 'get_ucp_users':
|
||||
try {
|
||||
FirewallManager::ensureUcpDeployed();
|
||||
} catch (\Throwable) {}
|
||||
$users = UcpManager::listUsers($db);
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'users' => $users,
|
||||
'ucp_users' => $users,
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'save_ucp_user':
|
||||
$users = UcpManager::saveUser($db, $payload);
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'users' => $users,
|
||||
'ucp_users' => $users,
|
||||
'message' => 'Compte utilisateur UCP enregistré avec succès.',
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'delete_ucp_user':
|
||||
$uid = (int)($payload['id'] ?? 0);
|
||||
$users = UcpManager::deleteUser($db, $uid);
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'users' => $users,
|
||||
'ucp_users' => $users,
|
||||
'message' => 'Compte utilisateur UCP supprimé.',
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'get_cdr':
|
||||
$filters = [
|
||||
'limit' => (int)($payload['limit'] ?? $_GET['limit'] ?? 100),
|
||||
@@ -2546,6 +2621,7 @@ try {
|
||||
break;
|
||||
|
||||
case 'run_update':
|
||||
@set_time_limit(300);
|
||||
$output = [];
|
||||
$code = 0;
|
||||
exec("sudo /var/www/nexus-pbx/scripts/update.sh 2>&1", $output, $code);
|
||||
|
||||
+647
-97
File diff suppressed because it is too large.
Load diff
+280
-36
@@ -41,7 +41,7 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="nexus-app-version" content="<?= htmlspecialchars($appVersion) ?>">
|
||||
<title><?= $isDevEnv ? '🛠️ [DEV] ' : '' ?>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="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>
|
||||
@@ -338,6 +338,11 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
|
||||
<span>• Annuaire d'Entreprise</span>
|
||||
<span id="nav-badge-phonebook" class="px-1.5 py-0.2 text-[10px] rounded-full bg-anthracite-800 text-teal-300 border border-anthracite-700 font-bold">0</span>
|
||||
</button>
|
||||
<button type="button" data-tab="ucp" onclick="switchTab('ucp')"
|
||||
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">
|
||||
<span>• Portail Utilisateur (UCP)</span>
|
||||
<span id="nav-badge-ucp" class="px-1.5 py-0.2 text-[10px] rounded-full bg-anthracite-800 text-sky-300 border border-anthracite-700 font-bold">0</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -532,7 +537,7 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
|
||||
<!-- Badge Notification Mise à Jour Disponible -->
|
||||
<button id="header-update-badge" type="button" onclick="openUpdatePanel()" style="display:none;"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-bold bg-amber-500/20 hover:bg-amber-500/30 text-amber-300 border border-amber-500/40 shadow-sm transition animate-pulse" title="Une mise à jour NexusPBX est disponible ! Cliquez pour voir les détails.">
|
||||
<span>🚀</span>
|
||||
<span><svg class="w-4 h-4 shrink-0 inline-block" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.59 14.37a6 6 0 01-5.84 7.38v-4.8m5.84-2.58a14.98 14.98 0 006.16-12.12A14.98 14.98 0 009.631 8.41m5.96 5.96a14.926 14.926 0 01-5.841 2.58m-.119-8.54a6 6 0 00-7.381 5.84h4.8m2.581-5.84a14.927 14.927 0 00-2.58 5.84m2.699 2.7c-.103.021-.207.041-.311.06a15.09 15.09 0 01-2.448-2.448 14.9 14.9 0 01.06-.312m-2.24 2.39a4.493 4.493 0 00-1.757 4.306 4.493 4.493 0 004.306-1.758M16.5 9a1.5 1.5 0 11-3 0 1.5 1.5 0 013 0z"/></svg></span>
|
||||
<span id="header-update-text">Mise à jour dispo</span>
|
||||
</button>
|
||||
|
||||
@@ -1378,14 +1383,14 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
|
||||
<!-- Bannière d'aide rapide Paging & Intercom 1-à-1 -->
|
||||
<div class="p-4 rounded-2xl bg-anthracite-800/90 border border-purple-500/30 grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-8 h-8 rounded-xl bg-purple-500/20 border border-purple-500/40 flex items-center justify-center text-purple-300 shrink-0 font-bold text-sm">📢</div>
|
||||
<div class="w-8 h-8 rounded-xl bg-purple-500/20 border border-purple-500/40 flex items-center justify-center text-purple-300 shrink-0 font-bold text-sm"><svg class="w-4 h-4 shrink-0 inline-block" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M10.34 15.84c-.688-.06-1.386-.09-2.09-.09H7.5a4.5 4.5 0 110-9h.75c.704 0 1.402-.03 2.09-.09m0 9.18c.253.962.584 1.892.985 2.783.247.55.06 1.21-.463 1.511l-.657.38c-.551.318-1.26.117-1.527-.461a20.845 20.845 0 01-1.44-4.282m3.102.069a18.03 18.03 0 01-.59-4.59c0-1.586.205-3.124.59-4.59m0 9.18a23.848 23.848 0 018.835 2.535M10.34 6.66a23.847 23.847 0 008.835-2.535m0 0A23.74 23.74 0 0018.795 3m.38 1.125a23.91 23.91 0 011.014 5.395m-1.014 8.855c-.118.38-.245.754-.38 1.125m.38-1.125a23.91 23.91 0 001.014-5.395m0-3.46c.495.413.811 1.035.811 1.73 0 .695-.316 1.317-.811 1.73m0-3.46a24.347 24.347 0 010 3.46"/></svg></div>
|
||||
<div class="space-y-0.5">
|
||||
<div class="text-xs font-bold text-purple-200">Annonce Générale de Groupe (Paging)</div>
|
||||
<p class="text-[11px] text-slate-300">Composez le numéro du groupe configuré (ex: <span class="font-mono text-purple-300 font-bold">100</span>). Les téléphones cibles décrochent automatiquement sur haut-parleur après un bip d'avertissement sonore.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-8 h-8 rounded-xl bg-indigo-500/20 border border-indigo-500/40 flex items-center justify-center text-indigo-300 shrink-0 font-bold text-sm">📞</div>
|
||||
<div class="w-8 h-8 rounded-xl bg-indigo-500/20 border border-indigo-500/40 flex items-center justify-center text-indigo-300 shrink-0 font-bold text-sm"><svg class="w-4 h-4 shrink-0 inline-block" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 6.75c0 8.284 6.716 15 15 15h2.25a2.25 2.25 0 002.25-2.25v-1.372c0-.516-.351-.966-.852-1.091l-4.423-1.106c-.44-.11-.902.055-1.173.417l-.97 1.293c-.282.376-.769.542-1.21.38a12.035 12.035 0 01-7.143-7.143c-.162-.441.004-.928.38-1.21l1.293-.97c.363-.271.527-.734.417-1.173L6.963 3.102a1.125 1.125 0 00-1.091-.852H4.5A2.25 2.25 0 002.25 4.5v2.25z"/></svg></div>
|
||||
<div class="space-y-0.5">
|
||||
<div class="text-xs font-bold text-indigo-200">Interphonie Directe 1-à-1 (*80 + poste)</div>
|
||||
<p class="text-[11px] text-slate-300">Composez <span class="font-mono text-indigo-300 font-bold">*80</span> suivi du numéro de l'extension (ex: <span class="font-mono text-indigo-300 font-bold">*80101</span>) pour ouvrir directement le haut-parleur et le micro du poste en conversation duplex.</p>
|
||||
@@ -1643,6 +1648,7 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
|
||||
<form id="settings-form" class="space-y-4">
|
||||
<!-- Identité & Logo de l'Exploitant -->
|
||||
<input type="file" id="company-logo-file-input" accept="image/png,image/svg+xml,image/jpeg,image/webp,image/gif" class="hidden" onchange="handleCompanyLogoUpload(this)">
|
||||
<input type="file" id="ucp-logo-file-input" accept="image/png,image/svg+xml,image/jpeg,image/webp,image/gif" class="hidden" onchange="handleUcpLogoUpload(this)">
|
||||
<div class="p-4 rounded-xl bg-anthracite-950 border border-anthracite-700 space-y-3.5">
|
||||
<div class="flex items-center justify-between pb-2 border-b border-anthracite-700/60">
|
||||
<div>
|
||||
@@ -1670,6 +1676,28 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
|
||||
class="w-full px-3 py-2 rounded-lg bg-anthracite-900 border border-anthracite-700 text-sm text-white">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Logo spécifique UCP (optionnel, fond clair) -->
|
||||
<div class="pt-3 border-t border-anthracite-800 flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<div id="settings-ucp-preview-slot" class="w-32 h-12 rounded-lg bg-white/95 border border-dashed border-slate-300 flex items-center justify-center p-1.5 shrink-0">
|
||||
<img id="settings-ucp-preview-img" src="" alt="Aperçu Logo UCP" class="max-h-9 max-w-full object-contain hidden">
|
||||
<span id="settings-ucp-preview-empty" class="text-[10px] text-slate-500 italic text-center leading-tight">Hérite du logo principal</span>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="text-xs font-semibold text-slate-200">Logo UCP (Portail Utilisateur — optionnel)</div>
|
||||
<p class="text-[11px] text-slate-400">Si vide, l'UCP réutilise le logo principal (ou NexusPBX). Utile pour importer un logo foncé adapté au thème clair UCP.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<button type="button" onclick="triggerUcpLogoPicker()" class="px-3 py-1.5 rounded-lg bg-slate-700 hover:bg-slate-600 text-xs text-white font-semibold transition">
|
||||
Importer Logo UCP
|
||||
</button>
|
||||
<button type="button" onclick="clearUcpLogo()" class="px-2.5 py-1.5 rounded-lg bg-anthracite-800 hover:bg-red-500/20 border border-anthracite-700 text-xs text-slate-300 hover:text-red-300 transition">
|
||||
Effacer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
@@ -2199,6 +2227,7 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
|
||||
<select id="manual-ban-jail" class="w-full sm:w-48 px-3 py-2 rounded-lg bg-anthracite-950 border border-anthracite-700 text-xs text-white">
|
||||
<option value="asterisk">Téléphonie SIP</option>
|
||||
<option value="nexus-web">Interface Web Admin</option>
|
||||
<option value="nexus-ucp">Portail Utilisateur (UCP)</option>
|
||||
<option value="sshd">Console SSH</option>
|
||||
</select>
|
||||
<button type="submit"
|
||||
@@ -2281,6 +2310,55 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Portail Utilisateur Isolé (UCP) & Fail2ban dédié -->
|
||||
<div class="p-3.5 rounded-xl bg-anthracite-950 border border-anthracite-700 space-y-3">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<span class="text-xs font-bold uppercase tracking-wider text-sky-300">Portail Utilisateur Isolé (UCP)</span>
|
||||
<p class="text-[11px] text-slate-400 leading-tight mt-0.5">VHost Nginx séparé de l'Admin avec prison Fail2ban <code class="text-sky-300">[nexus-ucp]</code>.</p>
|
||||
</div>
|
||||
<label class="relative inline-flex items-center cursor-pointer shrink-0 mt-0.5">
|
||||
<input type="checkbox" name="ucp_enabled" id="ucp_enabled" value="1" class="sr-only peer" checked>
|
||||
<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-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-sky-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2.5">
|
||||
<div>
|
||||
<label class="block text-[11px] font-medium text-slate-300 mb-1">Port HTTPS UCP</label>
|
||||
<input type="number" name="ucp_https_port" id="ucp_https_port" value="8444" min="0" max="65535"
|
||||
class="w-full px-2.5 py-1.5 rounded-lg bg-anthracite-900 border border-anthracite-700 text-xs text-white font-mono">
|
||||
<p class="text-[10px] text-slate-400 mt-0.5">Défaut : 8444 (0 = désactivé)</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-[11px] font-medium text-slate-300 mb-1">Port HTTP UCP (Proxy)</label>
|
||||
<input type="number" name="ucp_http_port" id="ucp_http_port" value="8084" min="0" max="65535"
|
||||
class="w-full px-2.5 py-1.5 rounded-lg bg-anthracite-900 border border-anthracite-700 text-xs text-white font-mono">
|
||||
<p class="text-[10px] text-slate-400 mt-0.5">Défaut : 8084 (0 = désactivé)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2.5 pt-1 border-t border-anthracite-800">
|
||||
<div>
|
||||
<label class="block text-[11px] font-medium text-slate-300 mb-1">Max échecs UCP</label>
|
||||
<select name="fail2ban_ucp_maxretry" class="w-full px-2.5 py-1.5 rounded-lg bg-anthracite-900 border border-anthracite-700 text-xs text-white">
|
||||
<option value="2">2 tentatives</option>
|
||||
<option value="3" selected>3 tentatives</option>
|
||||
<option value="5">5 tentatives</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-[11px] font-medium text-slate-300 mb-1">Durée ban UCP</label>
|
||||
<select name="fail2ban_ucp_bantime" class="w-full px-2.5 py-1.5 rounded-lg bg-anthracite-900 border border-anthracite-700 text-xs text-white">
|
||||
<option value="3600" selected>1 heure</option>
|
||||
<option value="43200">12 heures</option>
|
||||
<option value="86400">24 heures</option>
|
||||
<option value="604800">7 jours</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Liste Blanche Permanente (IgnoreIP) -->
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
@@ -2304,6 +2382,80 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ========================================================= -->
|
||||
<!-- ONGLET UCP : PORTAIL UTILISATEUR & COMPTES -->
|
||||
<!-- ========================================================= -->
|
||||
<section id="tab-ucp" class="tab-panel hidden space-y-6">
|
||||
<!-- En-tête & État d'exposition réseau UCP -->
|
||||
<div class="rounded-2xl bg-anthracite-900 border border-anthracite-800 p-5 flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="w-11 h-11 rounded-xl bg-sky-500/15 border border-sky-500/30 flex items-center justify-center shrink-0">
|
||||
<svg class="w-6 h-6 text-sky-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center gap-2.5 flex-wrap">
|
||||
<h3 class="text-base font-bold text-white">Portail Utilisateur (UCP)</h3>
|
||||
<span id="ucp-service-status-badge" class="px-2 py-0.5 rounded-full text-[11px] font-semibold bg-emerald-500/15 text-emerald-300 border border-emerald-500/30">
|
||||
Vérification...
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs text-slate-400 mt-1">
|
||||
VirtualHost Nginx isolé sur <code class="text-sky-300">/var/www/nexus-pbx/ucp</code> — aucun accès à l'administration, protégé par Fail2ban (<code class="text-sky-300">nexus-ucp</code>).
|
||||
</p>
|
||||
<div id="ucp-access-urls" class="mt-2.5 flex flex-wrap items-center gap-2 text-xs"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2.5 shrink-0">
|
||||
<button type="button" onclick="switchTab('firewall')"
|
||||
class="px-3.5 py-2 rounded-xl bg-anthracite-800 hover:bg-anthracite-750 text-slate-200 border border-anthracite-700 text-xs font-semibold transition flex items-center gap-2">
|
||||
<svg class="w-4 h-4 text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
Configurer les Ports & Fail2ban
|
||||
</button>
|
||||
<button type="button" onclick="openUcpUserModal()"
|
||||
class="px-4 py-2 rounded-xl bg-sky-600 hover:bg-sky-500 text-white text-xs font-bold shadow-lg shadow-sky-600/20 transition flex items-center gap-1.5">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Nouvel Utilisateur UCP
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tableau des utilisateurs UCP -->
|
||||
<div class="rounded-2xl bg-anthracite-900 border border-anthracite-800 overflow-hidden">
|
||||
<div class="px-5 py-4 border-b border-anthracite-800 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h4 class="text-sm font-bold text-white">Comptes Portail Utilisateur</h4>
|
||||
<p class="text-[11px] text-slate-400">Chaque compte ne peut gérer que les extensions SIP qui lui sont explicitement assignées.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr class="border-b border-anthracite-800 bg-anthracite-950/60 text-[11px] font-semibold uppercase tracking-wider text-slate-400">
|
||||
<th class="px-5 py-3">Identifiant</th>
|
||||
<th class="px-5 py-3">Nom affiché & Email</th>
|
||||
<th class="px-5 py-3">Extensions liées</th>
|
||||
<th class="px-5 py-3">Statut</th>
|
||||
<th class="px-5 py-3">Dernière connexion</th>
|
||||
<th class="px-5 py-3 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="ucp-users-table-body" class="divide-y divide-anthracite-800/70 text-xs">
|
||||
<tr>
|
||||
<td colspan="6" class="px-5 py-8 text-center text-slate-500">Chargement des utilisateurs UCP...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ========================================================= -->
|
||||
<!-- ONGLET 5 : CODES DE FONCTIONS & SERVICES TÉLÉPHONIQUES -->
|
||||
<!-- ========================================================= -->
|
||||
@@ -2353,8 +2505,9 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
|
||||
<div class="bg-anthracite-900 p-2 rounded-lg border border-anthracite-700"><span class="text-slate-400 block text-[10px] uppercase font-sans">Ligne (Line)</span><strong class="text-white">Line 1</strong></div>
|
||||
<div class="bg-anthracite-900 p-2 rounded-lg border border-indigo-500/40 bg-indigo-950/30"><span class="text-indigo-300 block text-[10px] uppercase font-sans">Extension (Pickup)</span><strong class="text-indigo-300 font-bold">**</strong></div>
|
||||
</div>
|
||||
<div class="text-[11px] text-slate-400 pt-1">
|
||||
💡 Quand le poste surveillé sonne, sa touche clignote : un simple appui dessus intercepte l'appel immédiatement via le préfixe <code class="text-indigo-300">**</code> !
|
||||
<div class="text-[11px] text-slate-400 pt-1 flex items-start gap-1.5">
|
||||
<svg class="w-3.5 h-3.5 shrink-0 text-amber-400 mt-0.5" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 18v-5.25m0 0a6.01 6.01 0 001.5-.189m-1.5.189a6.01 6.01 0 01-1.5-.189m3.75 7.478a12.06 12.06 0 01-4.5 0m3.75 2.383a14.406 14.406 0 01-3 0M14.25 18v-.192c0-.983.658-1.823 1.508-2.316a7.5 7.5 0 10-7.517 0c.85.493 1.509 1.333 1.509 2.316V18"/></svg>
|
||||
<span>Quand le poste surveillé sonne, sa touche clignote : un simple appui dessus intercepte l'appel immédiatement via le préfixe <code class="text-indigo-300">**</code> !</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2430,7 +2583,7 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
|
||||
<button type="button" onclick="detectPublicIp()" id="btn-detect-ip"
|
||||
class="text-[11px] text-cyan-400 hover:text-cyan-300 flex items-center gap-1 font-semibold transition">
|
||||
<span id="spinner-detect-ip" class="hidden animate-spin">↻</span>
|
||||
<span>🔍 Détecter l'IP publique</span>
|
||||
<svg class="w-3.5 h-3.5 shrink-0 inline-block" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"/></svg><span>Détecter l'IP publique</span>
|
||||
</button>
|
||||
</div>
|
||||
<input type="text" id="sip-external-ip" name="external_ip" placeholder="ex: sip.mondomaine.com ou 198.51.100.1"
|
||||
@@ -2446,7 +2599,7 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
|
||||
<label class="block text-xs font-semibold text-slate-200">Réseaux Locaux (Local Networks - CIDR) *</label>
|
||||
<button type="button" onclick="addLocalNetworkField()"
|
||||
class="text-[11px] text-emerald-400 hover:text-emerald-300 font-semibold flex items-center gap-1 transition">
|
||||
<span>➕ Ajouter un réseau local</span>
|
||||
<svg class="w-3.5 h-3.5 shrink-0 inline-block" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"/></svg><span>Ajouter un réseau local</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="local-networks-container" class="space-y-2 max-h-48 overflow-y-auto pr-1"></div>
|
||||
@@ -2939,9 +3092,9 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
|
||||
<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>
|
||||
class="px-2.5 py-2 rounded-xl bg-anthracite-900 border border-anthracite-700 text-slate-300 hover:text-white transition"><svg class="w-3.5 h-3.5 shrink-0 inline-block" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"/></svg></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>
|
||||
class="px-2.5 py-2 rounded-xl bg-anthracite-900 border border-anthracite-700 text-slate-300 hover:text-white transition"><svg class="w-3.5 h-3.5 shrink-0 inline-block" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 01-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 011.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 00-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 01-1.125-1.125v-9.25m12 6.625v-1.875a3.375 3.375 0 00-3.375-3.375h-1.5a1.125 1.125 0 01-1.125-1.125v-1.5a3.375 3.375 0 00-3.375-3.375H9.75"/></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -3030,7 +3183,7 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
|
||||
</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>.
|
||||
<svg class="w-4 h-4 shrink-0 inline-block text-amber-400" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 18v-5.25m0 0a6.01 6.01 0 001.5-.189m-1.5.189a6.01 6.01 0 01-1.5-.189m3.75 7.478a12.06 12.06 0 01-4.5 0m3.75 2.383a14.406 14.406 0 01-3 0M14.25 18v-.192c0-.983.658-1.823 1.508-2.316a7.5 7.5 0 10-7.517 0c.85.493 1.509 1.333 1.509 2.316V18"/></svg> <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>
|
||||
@@ -3052,11 +3205,11 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
|
||||
<div class="flex items-center gap-2 border-b border-anthracite-700 pb-2 mb-4">
|
||||
<button type="button" id="tab-btn-ext-general" onclick="switchExtModalTab('general')"
|
||||
class="px-3 py-1.5 rounded-lg text-xs font-bold transition bg-indigo-600 text-white">
|
||||
⚙️ Général & SIP
|
||||
<span class="inline-flex items-center gap-1.5"><svg class="w-3.5 h-3.5 shrink-0 inline-block" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z"/><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg><span>Général & SIP</span></span>
|
||||
</button>
|
||||
<button type="button" id="tab-btn-ext-voicemail" onclick="switchExtModalTab('voicemail')"
|
||||
class="px-3 py-1.5 rounded-lg text-xs font-semibold transition text-slate-300 hover:text-white hover:bg-anthracite-700 flex items-center gap-1.5">
|
||||
<span>📟 Messagerie Vocale</span>
|
||||
<span class="inline-flex items-center gap-1.5"><svg class="w-3.5 h-3.5 shrink-0 inline-block" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M21.75 9v.906a2.25 2.25 0 01-1.183 1.981l-6.478 3.488M2.25 9v.906a2.25 2.25 0 001.183 1.981l6.478 3.488m8.839 2.51l-4.66-2.51m0 0l-1.023-.55a2.25 2.25 0 00-2.134 0l-1.022.55m0 0l-4.661 2.51m16.5 1.615a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V8.844a2.25 2.25 0 011.183-1.98l7.5-4.04a2.25 2.25 0 012.134 0l7.5 4.04a2.25 2.25 0 011.183 1.98V19.5z"/></svg><span>Messagerie Vocale</span></span>
|
||||
<span id="badge-ext-vm-status" class="w-2 h-2 rounded-full bg-slate-500"></span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -3134,6 +3287,20 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
|
||||
<p class="text-[11px] text-slate-400">Le mode optionnel négocie le SRTP automatiquement tout en évitant les échecs d'appels si le poste IP a le SRTP désactivé.</p>
|
||||
</div>
|
||||
|
||||
<label class="flex items-start gap-3 p-3.5 rounded-xl bg-anthracite-950 border border-anthracite-700 hover:border-cyan-500/50 cursor-pointer transition">
|
||||
<input type="checkbox" name="groundwire_enabled" id="ext-groundwire-enabled" value="1"
|
||||
class="mt-0.5 rounded bg-anthracite-900 border-anthracite-600 text-cyan-500 focus:ring-cyan-500">
|
||||
<div class="leading-snug">
|
||||
<div class="text-xs font-bold text-white flex items-center gap-2">
|
||||
<span>GroundWire XML</span>
|
||||
<span class="px-1.5 py-0.5 rounded text-[10px] font-semibold bg-cyan-500/15 text-cyan-300 border border-cyan-500/30">Softphone Mobile UCP</span>
|
||||
</div>
|
||||
<div class="text-[11px] text-slate-400 mt-0.5">
|
||||
Affiche les boutons d'auto-configuration Acrobits GroundWire (lien direct mobile + téléchargement XML) sur ce poste dans l'UCP.
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<div class="p-3.5 rounded-xl bg-anthracite-950 border border-anthracite-700 space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs font-bold text-cyan-300">Appartenance aux Groupes d'Extensions (Sites & Droits)</span>
|
||||
@@ -3234,7 +3401,7 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
|
||||
|
||||
<!-- Indicateur MWI Yealink -->
|
||||
<div class="p-3 rounded-xl bg-emerald-500/10 border border-emerald-500/30 flex items-start gap-2.5">
|
||||
<span class="text-base">💡</span>
|
||||
<svg class="w-4 h-4 shrink-0 inline-block text-amber-400" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 18v-5.25m0 0a6.01 6.01 0 001.5-.189m-1.5.189a6.01 6.01 0 01-1.5-.189m3.75 7.478a12.06 12.06 0 01-4.5 0m3.75 2.383a14.406 14.406 0 01-3 0M14.25 18v-.192c0-.983.658-1.823 1.508-2.316a7.5 7.5 0 10-7.517 0c.85.493 1.509 1.333 1.509 2.316V18"/></svg>
|
||||
<div class="text-xs text-emerald-200">
|
||||
<strong class="font-bold">Notification MWI automatique :</strong>
|
||||
Le voyant LED ou l'enveloppe clignoteront sur vos téléphones IP (Yealink, Fanvil, Grandstream, Snom...) dès qu'un message vocal est reçu.
|
||||
@@ -3568,11 +3735,11 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
|
||||
</label>
|
||||
<label class="cursor-pointer">
|
||||
<input type="radio" name="route_type" value="emergency" class="peer hidden">
|
||||
<span class="px-2.5 py-1 rounded-md text-xs font-semibold text-slate-300 peer-checked:bg-emerald-600 peer-checked:text-white transition inline-block">🚑 Urgence</span>
|
||||
<span class="px-2.5 py-1 rounded-md text-xs font-semibold text-slate-300 peer-checked:bg-emerald-600 peer-checked:text-white transition inline-block"><span class="inline-flex items-center gap-1"><svg class="w-3.5 h-3.5 shrink-0 inline-block text-rose-400" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m0-10.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.75c0 5.592 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.57-.598-3.75h-.152c-3.196 0-6.1-1.249-8.25-3.286zm0 13.036h.008v.008H12v-.008z"/></svg><span>Urgence</span></span></span>
|
||||
</label>
|
||||
<label class="cursor-pointer">
|
||||
<input type="radio" name="route_type" value="intracompany" class="peer hidden">
|
||||
<span class="px-2.5 py-1 rounded-md text-xs font-semibold text-slate-300 peer-checked:bg-cyan-600 peer-checked:text-white transition inline-block">🏢 Intra-Company</span>
|
||||
<span class="px-2.5 py-1 rounded-md text-xs font-semibold text-slate-300 peer-checked:bg-cyan-600 peer-checked:text-white transition inline-flex items-center gap-1"><svg class="w-3.5 h-3.5 shrink-0 text-cyan-400" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 21h16.5M4.5 3h15M5.25 3v18m13.5-18v18M9 6.75h1.5m-1.5 3h1.5m-1.5 3h1.5m3-6H15m-1.5 3H15m-1.5 3H15M9 21v-3.375c0-.621.504-1.125 1.125-1.125h3.75c.621 0 1.125.504 1.125 1.125V21"/></svg><span>Intra-Company</span></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -3614,11 +3781,11 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-200 mb-1">🎵 Musique d'attente (MoH)</label>
|
||||
<label class="block text-xs font-medium text-slate-200 mb-1"><span class="inline-flex items-center gap-1.5"><svg class="w-3.5 h-3.5 shrink-0 inline-block text-sky-400" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 9l10.5-3m0 6.553v3.75a2.25 2.25 0 01-1.632 2.163l-1.32.377a1.803 1.803 0 11-.99-3.467l2.31-.66a2.25 2.25 0 001.632-2.163zm0 0V2.25L9 5.25v10.303m0 0v3.75a2.25 2.25 0 01-1.632 2.163l-1.32.377a1.803 1.803 0 01-.99-3.467l2.31-.66A2.25 2.25 0 009 15.553z"/></svg><span>Musique d'attente (MoH)</span></span></label>
|
||||
<select id="ob-musicclass-select" name="moh_class" class="w-full px-3 py-2 rounded-lg bg-anthracite-900 border border-anthracite-700 text-xs text-white"></select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-200 mb-1">📢 Si Trunks indisponibles <span class="text-slate-400">(Échec / Congestion)</span></label>
|
||||
<label class="block text-xs font-medium text-slate-200 mb-1"><span class="inline-flex items-center gap-1.5"><svg class="w-4 h-4 shrink-0 inline-block" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M10.34 15.84c-.688-.06-1.386-.09-2.09-.09H7.5a4.5 4.5 0 110-9h.75c.704 0 1.402-.03 2.09-.09m0 9.18c.253.962.584 1.892.985 2.783.247.55.06 1.21-.463 1.511l-.657.38c-.551.318-1.26.117-1.527-.461a20.845 20.845 0 01-1.44-4.282m3.102.069a18.03 18.03 0 01-.59-4.59c0-1.586.205-3.124.59-4.59m0 9.18a23.848 23.848 0 018.835 2.535M10.34 6.66a23.847 23.847 0 008.835-2.535m0 0A23.74 23.74 0 0018.795 3m.38 1.125a23.91 23.91 0 011.014 5.395m-1.014 8.855c-.118.38-.245.754-.38 1.125m.38-1.125a23.91 23.91 0 001.014-5.395m0-3.46c.495.413.811 1.035.811 1.73 0 .695-.316 1.317-.811 1.73m0-3.46a24.347 24.347 0 010 3.46"/></svg><span>Si Trunks indisponibles <span class="text-slate-400">(Échec / Congestion)</span></label>
|
||||
<select id="ob-congestion-dest-select" name="congestion_dest" class="w-full px-3 py-2 rounded-lg bg-anthracite-900 border border-anthracite-700 text-xs text-white"></select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -3629,7 +3796,7 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
|
||||
<div class="p-4 rounded-xl bg-anthracite-950 border border-emerald-500/30 space-y-3">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<div class="text-xs font-bold uppercase tracking-wider text-emerald-300">🪄 Masques de numérotation pré-définis (1 clic)</div>
|
||||
<div class="text-xs font-bold uppercase tracking-wider text-emerald-300"><span class="inline-flex items-center gap-1.5"><svg class="w-3.5 h-3.5 shrink-0 inline-block" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"/></svg><span>Masques de numérotation pré-définis (1 clic)</span></span></div>
|
||||
<p class="text-[11px] text-slate-300">Ajoutez instantanément les masques standards ou créez vos propres règles <code class="text-indigo-300">(prepend) + préfixe | [match pattern / ID appelant]</code>.</p>
|
||||
</div>
|
||||
<button type="button" onclick="addOutboundPatternRow()"
|
||||
@@ -3645,19 +3812,19 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
|
||||
</button>
|
||||
<button type="button" onclick="applyOutboundPatternPreset('fr_free')"
|
||||
class="px-3 py-1.5 rounded-lg bg-anthracite-900 hover:bg-emerald-500/20 border border-anthracite-600 hover:border-emerald-500/40 text-xs font-semibold text-cyan-300 transition">
|
||||
🟢 Numéros Verts / Gratuits (080[0-9]XXXXXX)
|
||||
<span class="inline-block w-2 h-2 rounded-full bg-emerald-400 mr-1.5"></span>Numéros Verts / Gratuits (080[0-9]XXXXXX)
|
||||
</button>
|
||||
<button type="button" onclick="applyOutboundPatternPreset('fr_emergency')"
|
||||
class="px-3 py-1.5 rounded-lg bg-anthracite-900 hover:bg-amber-500/20 border border-anthracite-600 hover:border-amber-500/40 text-xs font-semibold text-amber-300 transition">
|
||||
🚑 Numéros d'Urgence (15, 17, 18, 112, 114)
|
||||
<span class="inline-flex items-center gap-1.5"><svg class="w-3.5 h-3.5 shrink-0 inline-block text-rose-400" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m0-10.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.75c0 5.592 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.57-.598-3.75h-.152c-3.196 0-6.1-1.249-8.25-3.286zm0 13.036h.008v.008H12v-.008z"/></svg><span>Numéros d'Urgence (15, 17, 18, 112, 114)</span></span>
|
||||
</button>
|
||||
<button type="button" onclick="applyOutboundPatternPreset('international')"
|
||||
class="px-3 py-1.5 rounded-lg bg-anthracite-900 hover:bg-indigo-500/20 border border-anthracite-600 hover:border-indigo-500/40 text-xs font-semibold text-indigo-300 transition">
|
||||
🌍 International (00. & +X.)
|
||||
<span class="inline-flex items-center gap-1.5"><svg class="w-3.5 h-3.5 shrink-0 inline-block text-cyan-400" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3"/></svg><span>International (00. & +X.)</span></span>
|
||||
</button>
|
||||
<button type="button" onclick="applyOutboundPatternPreset('all')"
|
||||
class="px-3 py-1.5 rounded-lg bg-anthracite-900 hover:bg-purple-500/20 border border-anthracite-600 hover:border-purple-500/40 text-xs font-semibold text-purple-300 transition">
|
||||
⭐ Tout autoriser (X.)
|
||||
class="px-3 py-1.5 rounded-lg bg-anthracite-900 hover:bg-purple-500/20 border border-anthracite-600 hover:border-purple-500/40 text-xs font-semibold text-purple-300 transition inline-flex items-center gap-1.5">
|
||||
<svg class="w-3.5 h-3.5 shrink-0 text-purple-400" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.499a.562.562 0 011.04 0l2.125 5.111a.563.563 0 00.475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 00-.182.557l1.285 5.385a.562.562 0 01-.84.61l-4.725-2.885a.563.563 0 00-.586 0L6.982 20.54a.562.562 0 01-.84-.61l1.285-5.386a.562.562 0 00-.182-.557l-4.204-3.602a.563.563 0 01.321-.988l5.518-.442a.563.563 0 00.475-.345L11.48 3.5z"/></svg><span>Tout autoriser (X.)</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -3708,7 +3875,7 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
|
||||
<div class="p-4 rounded-xl bg-anthracite-950 border border-cyan-500/40 space-y-3">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<div class="text-xs font-bold uppercase tracking-wider text-cyan-300">👥 Groupes d'Extensions Autorisés (ex: Groupe Nice, Groupe Paris)</div>
|
||||
<div class="text-xs font-bold uppercase tracking-wider text-cyan-300"><span class="inline-flex items-center gap-1.5"><svg class="w-3.5 h-3.5 shrink-0 inline-block text-cyan-400" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0z"/></svg><span>Groupes d'Extensions Autorisés (ex: Groupe Nice, Groupe Paris)</div>
|
||||
<p class="text-[11px] text-slate-300">Tous les postes rattachés à un groupe d'extensions coché ci-dessous auront le droit d'appeler via cette route sortante.</p>
|
||||
</div>
|
||||
<button type="button" onclick="closeModal('modal-outbound'); switchTab('extgroups'); openExtGroupModal();"
|
||||
@@ -3723,7 +3890,7 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
|
||||
<div class="p-4 rounded-xl bg-anthracite-950 border border-anthracite-700 space-y-3">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<div class="text-xs font-bold uppercase tracking-wider text-emerald-300">📱 Extension Routing (Postes individuels Autorisés ↔ Bloqués)</div>
|
||||
<div class="text-xs font-bold uppercase tracking-wider text-emerald-300"><span class="inline-flex items-center gap-1.5"><svg class="w-3.5 h-3.5 shrink-0 inline-block text-emerald-400" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3"/></svg><span>Extension Routing (Postes individuels Autorisés ↔ Bloqués)</div>
|
||||
<p class="text-[11px] text-slate-300">Cliquez sur un poste pour le déplacer entre <strong>Allowed Extensions</strong> et <strong>Blocked Extensions</strong>. Si un poste est bloqué ici, l'appel passera automatiquement à la Route Sortante suivante.</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -3795,7 +3962,7 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
|
||||
Trunk avec Enregistrement (REGISTER + Auth)
|
||||
</button>
|
||||
<button type="button" onclick="applyTrunkArchitecture('gateway_receive')" class="px-2.5 py-1 rounded-lg bg-anthracite-950 hover:bg-emerald-600/30 border border-emerald-500/50 text-emerald-300 font-medium transition flex items-center gap-1">
|
||||
<span>📟 Passerelle FXO / Ligne Box (Grandstream, Cisco SPA, AudioCodes)</span>
|
||||
<span class="inline-flex items-center gap-1.5"><svg class="w-3.5 h-3.5 shrink-0 inline-block" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M21.75 9v.906a2.25 2.25 0 01-1.183 1.981l-6.478 3.488M2.25 9v.906a2.25 2.25 0 001.183 1.981l6.478 3.488m8.839 2.51l-4.66-2.51m0 0l-1.023-.55a2.25 2.25 0 00-2.134 0l-1.022.55m0 0l-4.661 2.51m16.5 1.615a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V8.844a2.25 2.25 0 011.183-1.98l7.5-4.04a2.25 2.25 0 012.134 0l7.5 4.04a2.25 2.25 0 011.183 1.98V19.5z"/></svg><span>Passerelle FXO / Ligne Box (Grandstream, Cisco SPA, AudioCodes)</span>
|
||||
</button>
|
||||
<button type="button" onclick="applyTrunkArchitecture('ip_peering')" class="px-2.5 py-1 rounded-lg bg-anthracite-950 hover:bg-indigo-600/30 border border-anthracite-600 text-purple-300 font-medium transition">
|
||||
Peering IP / SBC / Passerelle (Sans REGISTER)
|
||||
@@ -3901,7 +4068,7 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
|
||||
<!-- Guide interactif d'aide pour Passerelles FXO (Grandstream, Cisco SPA...) -->
|
||||
<div id="trunk-receive-guide" class="hidden p-3.5 rounded-xl bg-emerald-950/40 border border-emerald-500/40 text-xs text-slate-200 space-y-2">
|
||||
<div class="flex items-center gap-2 font-bold text-emerald-300 text-xs">
|
||||
<span>💡 Guide Rapide : Paramétrage d'une passerelle FXO (Grandstream HT813/HT503, Cisco SPA3102...)</span>
|
||||
<span class="inline-flex items-center gap-1.5"><svg class="w-4 h-4 shrink-0 inline-block text-amber-400" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 18v-5.25m0 0a6.01 6.01 0 001.5-.189m-1.5.189a6.01 6.01 0 01-1.5-.189m3.75 7.478a12.06 12.06 0 01-4.5 0m3.75 2.383a14.406 14.406 0 01-3 0M14.25 18v-.192c0-.983.658-1.823 1.508-2.316a7.5 7.5 0 10-7.517 0c.85.493 1.509 1.333 1.509 2.316V18"/></svg> Guide Rapide : Paramétrage d'une passerelle FXO (Grandstream HT813/HT503, Cisco SPA3102...)</span>
|
||||
</div>
|
||||
<p class="text-[11px] text-slate-300 leading-relaxed">
|
||||
Dans l'interface web de votre passerelle (onglet <strong>FXO Port</strong> Grandstream ou <strong>Line 1/PSTN</strong> Cisco SPA relié à la prise téléphonique d'une Box Internet / modem FAI / ligne analogique) :
|
||||
@@ -4038,7 +4205,7 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
|
||||
<div class="p-3.5 rounded-xl bg-anthracite-950 border border-anthracite-700/80 flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div class="max-w-xl">
|
||||
<label class="block text-xs font-bold text-white mb-0.5 flex items-center gap-1.5">
|
||||
<span>📞 Options d'appel Asterisk (Asterisk Trunk Dial Options)</span>
|
||||
<span class="inline-flex items-center gap-1.5"><svg class="w-4 h-4 shrink-0 inline-block" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 6.75c0 8.284 6.716 15 15 15h2.25a2.25 2.25 0 002.25-2.25v-1.372c0-.516-.351-.966-.852-1.091l-4.423-1.106c-.44-.11-.902.055-1.173.417l-.97 1.293c-.282.376-.769.542-1.21.38a12.035 12.035 0 01-7.143-7.143c-.162-.441.004-.928.38-1.21l1.293-.97c.363-.271.527-.734.417-1.173L6.963 3.102a1.125 1.125 0 00-1.091-.852H4.5A2.25 2.25 0 002.25 4.5v2.25z"/></svg><span>Options d'appel Asterisk (Asterisk Trunk Dial Options)</span>
|
||||
<span class="px-1.5 py-0.2 rounded bg-indigo-500/20 text-indigo-300 text-[10px] font-mono font-semibold">FreePBX Dial Options</span>
|
||||
</label>
|
||||
<p class="text-[11px] text-slate-300">
|
||||
@@ -4073,7 +4240,7 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
|
||||
<div class="min-w-[280px]">
|
||||
<select name="moh_sound_id" id="trunk-moh-sound-id"
|
||||
class="w-full px-3 py-2 rounded-xl bg-anthracite-900 border border-anthracite-700 text-xs text-white focus:outline-none focus:border-amber-500 font-medium">
|
||||
<option value="0">🌐 Hériter de la musique générale par défaut</option>
|
||||
<option value="0">Hériter de la musique générale par défaut</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -4082,7 +4249,7 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
|
||||
<div class="p-3.5 rounded-xl bg-anthracite-950 border border-anthracite-700/80 flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div class="max-w-xl">
|
||||
<label class="block text-xs font-bold text-white mb-0.5 flex items-center gap-1.5">
|
||||
<span>🏷️ Préfixe Nom Appelant (CID Name Prefix)</span>
|
||||
<span class="inline-flex items-center gap-1.5"><svg class="w-3.5 h-3.5 shrink-0 inline-block text-amber-400" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9.568 3H5.25A2.25 2.25 0 003 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 005.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 009.568 3z"/></svg><span>Préfixe Nom Appelant (CID Name Prefix)</span>
|
||||
<span class="px-1.5 py-0.2 rounded bg-indigo-500/20 text-indigo-300 text-[10px] font-mono font-semibold">FreePBX CID Prefix</span>
|
||||
</label>
|
||||
<p class="text-[11px] text-slate-300">
|
||||
@@ -4108,7 +4275,7 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<button type="button" onclick="addArcepSpamBlockPreset()"
|
||||
class="px-3 py-1.5 rounded-xl bg-amber-500/20 hover:bg-amber-500/30 border border-amber-500/40 text-xs font-semibold text-amber-300 transition">
|
||||
🛡️ + Ajouter Pack Blocage Démarchage (0162, 0163, 0270, 0377, 0424, 0568, 0948...)
|
||||
<span class="inline-flex items-center gap-1.5"><svg class="w-3.5 h-3.5 shrink-0 inline-block" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z"/></svg><span>+ Ajouter Pack Blocage Démarchage</span></span> (0162, 0163, 0270, 0377, 0424, 0568, 0948...)
|
||||
</button>
|
||||
<button type="button" onclick="addTrunkInboundRouteRow('Any', 'Any', 'Nouvelle règle', 'ext-101', true)"
|
||||
class="px-3.5 py-1.5 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-xs font-semibold text-white shadow-md shadow-indigo-600/20 transition">
|
||||
@@ -4120,7 +4287,7 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
|
||||
<!-- Barre de filtre rapide dans les règles -->
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 pt-1">
|
||||
<input type="text" id="inbound-routes-filter" oninput="filterInboundRoutesRows(this.value)"
|
||||
placeholder="🔍 Filtrer vos règles (ex: 0162, Block, VIP, Issy, Occupation...)"
|
||||
placeholder="Filtrer vos règles (ex: 0162, Block, VIP, Issy, Occupation...)"
|
||||
class="w-full sm:w-96 px-3 py-1.5 rounded-lg bg-anthracite-900 border border-anthracite-700 text-xs text-white">
|
||||
<div class="text-[11px] text-slate-400">
|
||||
Syntaxe Asterisk : <code class="text-indigo-300">Any</code> = Tous • <code class="text-indigo-300">_0162XXXXXX</code> = Plage 0162 • <code class="text-indigo-300">0612345678</code> = Numéro exact
|
||||
@@ -4508,7 +4675,7 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
|
||||
<!-- Colonne Choix Invalide -->
|
||||
<div class="p-3.5 rounded-xl bg-anthracite-950/60 border border-anthracite-700 space-y-3">
|
||||
<div class="text-xs font-bold text-white flex items-center gap-1.5">
|
||||
<span class="text-red-400">⚠️</span> En cas de Touche Invalide
|
||||
<span class="inline-flex items-center gap-1.5"><svg class="w-3.5 h-3.5 shrink-0 inline-block text-amber-400" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"/></svg><span>En cas de Touche Invalide</span></span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -4535,7 +4702,7 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
|
||||
<!-- Colonne Expiration / Timeout -->
|
||||
<div class="p-3.5 rounded-xl bg-anthracite-950/60 border border-anthracite-700 space-y-3">
|
||||
<div class="text-xs font-bold text-white flex items-center gap-1.5">
|
||||
<span class="text-amber-400">⏱️</span> En cas de Timeout (Aucune touche pressée)
|
||||
<svg class="w-4 h-4 shrink-0 text-amber-400" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"/></svg> En cas de Timeout (Aucune touche pressée)
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
@@ -5421,7 +5588,7 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
|
||||
</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>
|
||||
<svg class="w-3.5 h-3.5 shrink-0 inline-block text-amber-400" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"/></svg>
|
||||
<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>
|
||||
@@ -5458,6 +5625,83 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ========================================================= -->
|
||||
<!-- MODALE UTILISATEUR UCP (CRÉATION / ÉDITION) -->
|
||||
<!-- ========================================================= -->
|
||||
<div id="modal-ucp-user" class="fixed inset-0 z-50 hidden items-center justify-center bg-black/75 backdrop-blur-sm p-4">
|
||||
<div class="w-full max-w-xl rounded-2xl bg-anthracite-900 border border-anthracite-700 shadow-2xl overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-anthracite-800 flex items-center justify-between bg-anthracite-950/60">
|
||||
<div>
|
||||
<h3 id="ucp-modal-title" class="text-sm font-bold text-white">Nouvel utilisateur UCP</h3>
|
||||
<p class="text-[11px] text-slate-400">Attribuez des identifiants et une ou plusieurs extensions SIP</p>
|
||||
</div>
|
||||
<button type="button" onclick="closeUcpUserModal()" class="text-slate-400 hover:text-white p-1.5 rounded-lg hover:bg-anthracite-800 transition">✕</button>
|
||||
</div>
|
||||
<form id="form-ucp-user" onsubmit="saveUcpUser(event)" class="p-6 space-y-4 max-h-[75vh] overflow-y-auto">
|
||||
<input type="hidden" id="ucp-user-id" value="0">
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-300 mb-1.5">Identifiant de connexion <span class="text-rose-400">*</span></label>
|
||||
<input type="text" id="ucp-user-username" required autocomplete="off"
|
||||
placeholder="ex: j.dupont"
|
||||
class="w-full px-3.5 py-2 rounded-xl bg-anthracite-950 border border-anthracite-700 text-sm text-white focus:border-sky-500 focus:outline-none">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-300 mb-1.5">Nom complet affiché</label>
|
||||
<input type="text" id="ucp-user-display-name" autocomplete="off"
|
||||
placeholder="ex: Jean Dupont"
|
||||
class="w-full px-3.5 py-2 rounded-xl bg-anthracite-950 border border-anthracite-700 text-sm text-white focus:border-sky-500 focus:outline-none">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-300 mb-1.5">Adresse Email</label>
|
||||
<input type="email" id="ucp-user-email" autocomplete="off"
|
||||
placeholder="jean.dupont@entreprise.fr"
|
||||
class="w-full px-3.5 py-2 rounded-xl bg-anthracite-950 border border-anthracite-700 text-sm text-white focus:border-sky-500 focus:outline-none">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-300 mb-1.5">
|
||||
Mot de passe <span id="ucp-password-hint" class="text-[11px] font-normal text-slate-400">(min. 6 car.)</span>
|
||||
</label>
|
||||
<input type="password" id="ucp-user-password" autocomplete="new-password"
|
||||
placeholder="••••••••"
|
||||
class="w-full px-3.5 py-2 rounded-xl bg-anthracite-950 border border-anthracite-700 text-sm text-white focus:border-sky-500 focus:outline-none">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<label class="text-xs font-semibold text-slate-300">Extensions SIP autorisées</label>
|
||||
<span class="text-[11px] text-slate-400">Cochez un ou plusieurs postes</span>
|
||||
</div>
|
||||
<div id="ucp-user-extensions-container"
|
||||
class="grid grid-cols-1 sm:grid-cols-2 gap-2 max-h-48 overflow-y-auto p-3 rounded-xl bg-anthracite-950 border border-anthracite-800">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="flex items-center gap-2.5 cursor-pointer select-none pt-1">
|
||||
<input type="checkbox" id="ucp-user-enabled" checked
|
||||
class="w-4 h-4 rounded border-anthracite-600 bg-anthracite-950 text-sky-500 focus:ring-sky-500">
|
||||
<span class="text-xs font-medium text-slate-300">Compte actif (autoriser la connexion au portail UCP)</span>
|
||||
</label>
|
||||
|
||||
<div class="pt-3 border-t border-anthracite-800 flex items-center justify-end gap-3">
|
||||
<button type="button" onclick="closeUcpUserModal()"
|
||||
class="px-4 py-2 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="submit" id="btn-save-ucp-user"
|
||||
class="px-5 py-2 rounded-xl text-xs font-bold bg-sky-600 hover:bg-sky-500 text-white shadow-lg shadow-sky-600/20 transition">
|
||||
Enregistrer l'utilisateur
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/assets/app.js?v=<?= filemtime(__DIR__ . '/assets/app.js') ?>"></script>
|
||||
</body>
|
||||
</html>
|
||||
+12
-1
@@ -19,6 +19,8 @@ switch ($cmd) {
|
||||
echo "• Pare-feu & Filtrage : " . ($settings['firewall_enabled'] ? "\033[32mACTIF\033[0m" : "\033[33mINACTIF\033[0m") . "\n";
|
||||
echo "• Mode Strict Certbot 80 : " . ($settings['certbot_strict_port80'] ? "OUI (Port 80 ACME seul)" : "NON (Port 80 ouvert)") . "\n";
|
||||
echo "• Port HTTPS Admin : " . ($settings['admin_https_port'] ?? 8443) . "\n";
|
||||
$ucpSt = !empty($settings['ucp_enabled']) ? "\033[32mACTIF\033[0m (HTTPS:" . ($settings['ucp_https_port'] ?? 8444) . " / HTTP:" . ($settings['ucp_http_port'] ?? 8084) . ")" : "\033[33mINACTIF\033[0m";
|
||||
echo "• Portail Utilisateur UCP: " . $ucpSt . "\n";
|
||||
echo "• Total IPs Bannies : " . $st['total_banned'] . "\n";
|
||||
if (!empty($st['banned_ips'])) {
|
||||
echo "\nListe des IPs bannies :\n";
|
||||
@@ -72,8 +74,17 @@ switch ($cmd) {
|
||||
echo ($res['success'] ? "\033[32m[OK]\033[0m " : "\033[31m[ERREUR]\033[0m ") . $res['message'] . "\n";
|
||||
break;
|
||||
|
||||
case 'init-ucp':
|
||||
case 'sync-ucp':
|
||||
case 'apply':
|
||||
$settings = FirewallManager::getSettings();
|
||||
$res = FirewallManager::saveSettings($settings);
|
||||
@unlink('/tmp/nexus_fw_status_cache.json');
|
||||
echo ($res['success'] ? "\033[32m[OK]\033[0m " : "\033[31m[ERREUR]\033[0m ") . ($res['message'] ?? '') . "\n";
|
||||
break;
|
||||
|
||||
default:
|
||||
echo "Usage: nexus-firewall [status|on|off|ban <ip>|unban <ip>]\n";
|
||||
echo "Usage: nexus-firewall [status|on|off|init-ucp|ban <ip>|unban <ip>]\n";
|
||||
exit(1);
|
||||
}
|
||||
|
||||
+69
-13
@@ -81,6 +81,9 @@ fi
|
||||
|
||||
echo -e "\n${C_YELLOW}-> Mise à jour vers la version v${REMOTE_VER}...${C_RESET}"
|
||||
|
||||
# Nettoyage préventif d'éventuels anciens dossiers temporaires HA dans /tmp
|
||||
find /tmp -maxdepth 1 \( -name 'nexus_cluster_extract_*' -o -name 'nexus_cluster_tar_*' -o -name 'nexus_cluster_recv_*' -o -name 'nexus_cluster_sync_*' -o -name 'nexus_cluster_db_*' \) -mmin +10 -exec rm -rf {} + 2>/dev/null || true
|
||||
|
||||
# 1. Sauvegarde préventive automatique (Disaster Recovery complète)
|
||||
echo -e "${C_GRAY}[1/6] Création d'une sauvegarde préventive du système (Disaster Recovery)...${C_RESET}"
|
||||
php -r '
|
||||
@@ -151,26 +154,68 @@ done
|
||||
# Mise à jour des privilèges sudoers (www-data) si accessible en écriture
|
||||
if [ -w /etc/sudoers.d ]; then
|
||||
cat << 'EOF_SUDO' > /etc/sudoers.d/nexus-pbx 2>/dev/null || true
|
||||
www-data ALL=(ALL) NOPASSWD: /usr/sbin/asterisk, /usr/sbin/nginx, /usr/bin/systemctl, /usr/bin/fail2ban-client, /bin/fail2ban-client, /var/www/nexus-pbx/scripts/nexus-firewall, /usr/bin/certbot, /usr/sbin/nft, /usr/sbin/iptables, /var/www/nexus-pbx/scripts/update.sh, /usr/local/bin/nexus-update, /var/www/nexus-pbx/scripts/nexus-cluster
|
||||
www-data ALL=(ALL) NOPASSWD: /usr/sbin/asterisk, /usr/sbin/nginx, /usr/bin/systemctl, /usr/bin/fail2ban-client, /bin/fail2ban-client, /var/www/nexus-pbx/scripts/nexus-firewall, /usr/bin/certbot, /usr/sbin/nft, /usr/sbin/iptables, /usr/sbin/ufw, /var/www/nexus-pbx/scripts/update.sh, /usr/local/bin/nexus-update, /var/www/nexus-pbx/scripts/nexus-cluster
|
||||
EOF_SUDO
|
||||
chmod 0440 /etc/sudoers.d/nexus-pbx 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# 3. Vérification des dépendances Debian
|
||||
# 3. Vérification des dépendances Debian & extensions PHP
|
||||
echo -e "${C_GRAY}[3/6] Vérification des composants et outils système...${C_RESET}"
|
||||
which ffmpeg >/dev/null 2>&1 && which rsync >/dev/null 2>&1 && which sox >/dev/null 2>&1 && which sshpass >/dev/null 2>&1 && which keepalived >/dev/null 2>&1 && which arping >/dev/null 2>&1 || {
|
||||
echo -e " -> Installation de paquets complémentaires..."
|
||||
(apt-get update -y -qq && apt-get install -y -qq --no-install-recommends acl ffmpeg rsync sox sshpass keepalived iputils-arping) 2>/dev/null || true
|
||||
}
|
||||
if ! which sqlite3 >/dev/null 2>&1 || ! which ffmpeg >/dev/null 2>&1 || ! which rsync >/dev/null 2>&1 || ! which sox >/dev/null 2>&1 || ! which sshpass >/dev/null 2>&1 || ! which keepalived >/dev/null 2>&1 || ! which arping >/dev/null 2>&1; then
|
||||
echo -e " -> Installation de paquets système complémentaires..."
|
||||
(apt-get update -y -qq && apt-get install -y -qq --no-install-recommends sqlite3 acl ffmpeg rsync sox sshpass keepalived iputils-arping) 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# 4. Exécution des migrations SQLite et régénération Dialplan
|
||||
echo -e "${C_GRAY}[4/6] Exécution des migrations de base de données & régénération dialplan...${C_RESET}"
|
||||
PHP_MODS=$(php -m 2>/dev/null || true)
|
||||
if ! echo "$PHP_MODS" | grep -qi "^sqlite3$" || ! echo "$PHP_MODS" | grep -qi "^curl$" || ! echo "$PHP_MODS" | grep -qi "^xml$" || ! echo "$PHP_MODS" | grep -qi "^mbstring$" || ! echo "$PHP_MODS" | grep -qi "^zip$"; then
|
||||
echo -e " -> Installation des extensions PHP manquantes..."
|
||||
(apt-get update -y -qq && apt-get install -y -qq --no-install-recommends php-sqlite3 php-curl php-xml php-mbstring php-zip) 2>/dev/null || true
|
||||
PHP_FPM_SVC=$(systemctl list-unit-files --type=service --no-legend 2>/dev/null | awk '/php.*-fpm\.service/ {print $1; exit}')
|
||||
[ -n "$PHP_FPM_SVC" ] && systemctl restart "$PHP_FPM_SVC" >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
# 4. Exécution des migrations SQLite, régénération Dialplan, Nginx & Fail2ban UCP
|
||||
echo -e "${C_GRAY}[4/6] Exécution des migrations de base de données & régénération dialplan/web...${C_RESET}"
|
||||
if [ -w /var/log ]; then
|
||||
touch /var/log/nexus_ucp_security.log 2>/dev/null || true
|
||||
chown www-data:asterisk /var/log/nexus_ucp_security.log 2>/dev/null || true
|
||||
chmod 0664 /var/log/nexus_ucp_security.log 2>/dev/null || true
|
||||
fi
|
||||
[ -d "$APP_DIR/ucp" ] && chmod 0755 "$APP_DIR/ucp" "$APP_DIR/ucp/assets" 2>/dev/null || true
|
||||
php -r '
|
||||
require_once "/var/www/nexus-pbx/src/Database.php";
|
||||
require_once "/var/www/nexus-pbx/src/ConfigGenerator.php";
|
||||
require_once "/var/www/nexus-pbx/src/FirewallManager.php";
|
||||
$db = App\Database::getConnection(true);
|
||||
$gen = new App\ConfigGenerator($db);
|
||||
$gen->writeAllConfigs();
|
||||
try {
|
||||
$st = App\FirewallManager::getSettings();
|
||||
App\FirewallManager::ensureUcpFail2banArtifacts();
|
||||
App\FirewallManager::applyNginxConfig(
|
||||
(int)($st["admin_https_port"] ?? 8443),
|
||||
(int)($st["certbot_strict_port80"] ?? 0),
|
||||
(int)($st["ucp_enabled"] ?? 1),
|
||||
(int)($st["ucp_https_port"] ?? 8444),
|
||||
(int)($st["ucp_http_port"] ?? 8084)
|
||||
);
|
||||
App\FirewallManager::generateJailLocal(
|
||||
(string)($st["fail2ban_whitelist_ips"] ?? ""),
|
||||
(int)($st["fail2ban_sip_maxretry"] ?? 3),
|
||||
(int)($st["fail2ban_sip_findtime"] ?? 600),
|
||||
(int)($st["fail2ban_sip_bantime"] ?? 3600),
|
||||
(int)($st["fail2ban_web_maxretry"] ?? 3),
|
||||
(int)($st["fail2ban_web_bantime"] ?? 3600),
|
||||
(int)($st["admin_https_port"] ?? 8443),
|
||||
(int)($st["ucp_enabled"] ?? 1),
|
||||
(int)($st["ucp_https_port"] ?? 8444),
|
||||
(int)($st["ucp_http_port"] ?? 8084),
|
||||
(int)($st["fail2ban_ucp_maxretry"] ?? 3),
|
||||
(int)($st["fail2ban_ucp_bantime"] ?? 3600)
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
fwrite(STDERR, "Warning UCP/Firewall update: " . $e->getMessage() . "\n");
|
||||
}
|
||||
'
|
||||
|
||||
# 5. Fix permissions & ACLs
|
||||
@@ -200,6 +245,9 @@ chmod 0664 /var/log/asterisk/cdr-csv/Master.csv 2>/dev/null || true
|
||||
setfacl -R -m u:www-data:rwx,g:asterisk:rwx /etc/asterisk /var/lib/asterisk/sounds /var/lib/asterisk/moh /var/spool/asterisk /var/log/asterisk 2>/dev/null || true
|
||||
setfacl -R -d -m u:www-data:rwx,g:asterisk:rwx /etc/asterisk /var/lib/asterisk/sounds /var/lib/asterisk/moh /var/spool/asterisk /var/log/asterisk 2>/dev/null || true
|
||||
chmod 0664 /etc/asterisk/*.conf /etc/asterisk/*.custom.conf 2>/dev/null || true
|
||||
chown root:asterisk /etc/fail2ban/jail.local /etc/nginx/sites-available/nexus-pbx 2>/dev/null || true
|
||||
chmod 0664 /etc/fail2ban/jail.local /etc/nginx/sites-available/nexus-pbx 2>/dev/null || true
|
||||
setfacl -m u:www-data:rw /etc/fail2ban/jail.local /etc/nginx/sites-available/nexus-pbx 2>/dev/null || true
|
||||
|
||||
# 6. Rechargement des services
|
||||
echo -e "${C_GRAY}[6/6] Rechargement à chaud d'Asterisk et des services web...${C_RESET}"
|
||||
@@ -211,8 +259,9 @@ asterisk -rx "dialplan reload" >/dev/null 2>&1 || true
|
||||
if [ -t 1 ]; then
|
||||
systemctl reload php8.4-fpm 2>/dev/null || systemctl reload php8.2-fpm 2>/dev/null || systemctl reload php*-fpm 2>/dev/null || true
|
||||
systemctl reload nginx 2>/dev/null || true
|
||||
fail2ban-client reload >/dev/null 2>&1 || true
|
||||
else
|
||||
(nohup bash -c "sleep 2 && (systemctl reload php8.4-fpm 2>/dev/null || systemctl reload php8.2-fpm 2>/dev/null || systemctl reload php*-fpm 2>/dev/null || true); systemctl reload nginx 2>/dev/null || true" >/dev/null 2>&1 &)
|
||||
(nohup bash -c "sleep 1 && (systemctl reload php8.4-fpm 2>/dev/null || systemctl reload php8.2-fpm 2>/dev/null || systemctl reload php*-fpm 2>/dev/null || true); systemctl reload nginx 2>/dev/null || true; fail2ban-client reload >/dev/null 2>&1 || true" </dev/null >/dev/null 2>&1 &)
|
||||
fi
|
||||
|
||||
NEW_VER="Inconnue"
|
||||
@@ -230,10 +279,17 @@ echo ""
|
||||
if [ -f "$APP_DIR/version.json" ]; then
|
||||
echo -e "${C_CYAN}${C_BOLD}Nouveautés de cette version :${C_RESET}"
|
||||
php -r '
|
||||
$v = json_decode(file_get_contents("/var/www/nexus-pbx/version.json"), true);
|
||||
if (!empty($v["changelog"])) {
|
||||
foreach ($v["changelog"] as $line) {
|
||||
echo " • " . $line . "\n";
|
||||
$v = json_decode(@file_get_contents("/var/www/nexus-pbx/version.json"), true);
|
||||
if (!empty($v["changelog"]) && is_array($v["changelog"])) {
|
||||
$first = $v["changelog"][0];
|
||||
if (is_array($first) && !empty($first["changes"])) {
|
||||
foreach ($first["changes"] as $line) {
|
||||
echo " • " . $line . "\n";
|
||||
}
|
||||
} else {
|
||||
foreach ($v["changelog"] as $line) {
|
||||
if (is_string($line)) echo " • " . $line . "\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
'
|
||||
|
||||
+259
-133
@@ -495,78 +495,98 @@ class ClusterManager
|
||||
throw new RuntimeException("Fichier de base de données introuvable : {$dbPath}");
|
||||
}
|
||||
|
||||
$this->cleanupStaleClusterTempFiles();
|
||||
$uniq = getmypid() . '_' . bin2hex(random_bytes(4));
|
||||
|
||||
// Snapshot 100% atomique de la base NexusPBX via VACUUM INTO / .backup officiel SQLite
|
||||
$tmpDbDump = '/tmp/nexus_cluster_db_' . time() . '.sqlite';
|
||||
@unlink($tmpDbDump);
|
||||
$tmpDbDump = '/tmp/nexus_cluster_db_' . $uniq . '.sqlite';
|
||||
$tmpArchive = '/tmp/nexus_cluster_sync_' . $uniq . '.tar.gz';
|
||||
$tmpAstdb = '/tmp/astdb_cluster_sync_' . $uniq . '.sqlite3';
|
||||
$tmpDbDir = '/tmp/nexus_cluster_tar_' . $uniq;
|
||||
$hasAstdb = false;
|
||||
|
||||
try {
|
||||
$this->db->exec("VACUUM INTO " . $this->db->quote($tmpDbDump));
|
||||
} catch (\Throwable $e) {
|
||||
shell_exec("sqlite3 " . escapeshellarg($dbPath) . " \".backup " . escapeshellarg($tmpDbDump) . "\" 2>/dev/null || true");
|
||||
}
|
||||
|
||||
if (!file_exists($tmpDbDump) || filesize($tmpDbDump) === 0) {
|
||||
$this->db->exec('PRAGMA wal_checkpoint(FULL);');
|
||||
@copy($dbPath, $tmpDbDump);
|
||||
}
|
||||
|
||||
$dbIntegrity = trim(shell_exec("sqlite3 " . escapeshellarg($tmpDbDump) . " 'PRAGMA integrity_check;' 2>/dev/null") ?? '');
|
||||
if ($dbIntegrity !== 'ok') {
|
||||
@unlink($tmpDbDump);
|
||||
throw new RuntimeException("Échec de vérification d'intégrité de la base locale avant réplication : {$dbIntegrity}");
|
||||
}
|
||||
try {
|
||||
$this->db->exec("VACUUM INTO " . $this->db->quote($tmpDbDump));
|
||||
} catch (\Throwable $e) {
|
||||
shell_exec("sqlite3 " . escapeshellarg($dbPath) . " \".backup " . escapeshellarg($tmpDbDump) . "\" 2>/dev/null || true");
|
||||
}
|
||||
|
||||
$tmpArchive = '/tmp/nexus_cluster_sync_' . time() . '.tar.gz';
|
||||
$tmpAstdb = '/tmp/astdb_cluster_sync.sqlite3';
|
||||
$hasAstdb = false;
|
||||
if (!file_exists($tmpDbDump) || filesize($tmpDbDump) === 0) {
|
||||
$this->db->exec('PRAGMA wal_checkpoint(FULL);');
|
||||
@copy($dbPath, $tmpDbDump);
|
||||
}
|
||||
|
||||
// Sauvegarde intègre et atomique de la base de contacts Asterisk (astdb.sqlite3)
|
||||
$astdbPath = '/var/lib/asterisk/astdb.sqlite3';
|
||||
if (file_exists($astdbPath) && is_readable($astdbPath)) {
|
||||
@unlink($tmpAstdb);
|
||||
shell_exec("sqlite3 " . escapeshellarg($astdbPath) . " \".backup " . escapeshellarg($tmpAstdb) . "\" 2>/dev/null || cp -f " . escapeshellarg($astdbPath) . " " . escapeshellarg($tmpAstdb) . " 2>/dev/null || true");
|
||||
if (file_exists($tmpAstdb) && filesize($tmpAstdb) > 0) {
|
||||
$integrity = trim(shell_exec("sqlite3 " . escapeshellarg($tmpAstdb) . " 'PRAGMA integrity_check;' 2>/dev/null") ?? '');
|
||||
if ($integrity === 'ok') {
|
||||
// Purger les souscriptions persistantes PJSIP de l'archive (incompatibles entre instances Asterisk)
|
||||
shell_exec("sqlite3 " . escapeshellarg($tmpAstdb) . " \"DELETE FROM astdb WHERE key LIKE '/subscription_persistence/%';\" 2>/dev/null || true");
|
||||
$hasAstdb = true;
|
||||
} else {
|
||||
@unlink($tmpAstdb);
|
||||
$dbIntegrity = $this->checkSqliteIntegrity($tmpDbDump);
|
||||
if ($dbIntegrity !== 'ok') {
|
||||
throw new RuntimeException("Échec de vérification d'intégrité de la base locale avant réplication : {$dbIntegrity}");
|
||||
}
|
||||
|
||||
// Sauvegarde intègre et atomique de la base de contacts Asterisk (astdb.sqlite3)
|
||||
$astdbPath = '/var/lib/asterisk/astdb.sqlite3';
|
||||
if (file_exists($astdbPath) && is_readable($astdbPath)) {
|
||||
@unlink($tmpAstdb);
|
||||
try {
|
||||
if (class_exists(\SQLite3::class)) {
|
||||
$srcAst = new \SQLite3($astdbPath, SQLITE3_OPEN_READONLY);
|
||||
$dstAst = new \SQLite3($tmpAstdb);
|
||||
$srcAst->backup($dstAst);
|
||||
$srcAst->close();
|
||||
$dstAst->close();
|
||||
} else {
|
||||
shell_exec("sqlite3 " . escapeshellarg($astdbPath) . " \".backup " . escapeshellarg($tmpAstdb) . "\" 2>/dev/null || cp -f " . escapeshellarg($astdbPath) . " " . escapeshellarg($tmpAstdb) . " 2>/dev/null || true");
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
@copy($astdbPath, $tmpAstdb);
|
||||
}
|
||||
if (file_exists($tmpAstdb) && filesize($tmpAstdb) > 0) {
|
||||
$integrity = $this->checkSqliteIntegrity($tmpAstdb);
|
||||
if ($integrity === 'ok') {
|
||||
// Purger les souscriptions persistantes PJSIP de l'archive (incompatibles entre instances Asterisk)
|
||||
try {
|
||||
$astPdo = new PDO('sqlite:' . $tmpAstdb);
|
||||
$astPdo->exec("DELETE FROM astdb WHERE key LIKE '/subscription_persistence/%'");
|
||||
$astPdo = null;
|
||||
} catch (\Throwable) {
|
||||
shell_exec("sqlite3 " . escapeshellarg($tmpAstdb) . " \"DELETE FROM astdb WHERE key LIKE '/subscription_persistence/%';\" 2>/dev/null || true");
|
||||
}
|
||||
$hasAstdb = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Préparation du dossier temporaire de tarage pour isoler database.sqlite
|
||||
$tmpDbDir = '/tmp/nexus_cluster_tar_' . time();
|
||||
@mkdir($tmpDbDir, 0755, true);
|
||||
rename($tmpDbDump, $tmpDbDir . '/database.sqlite');
|
||||
// Préparation du dossier temporaire de tarage pour isoler database.sqlite
|
||||
@mkdir($tmpDbDir, 0755, true);
|
||||
rename($tmpDbDump, $tmpDbDir . '/database.sqlite');
|
||||
|
||||
$cmd = "tar -czf " . escapeshellarg($tmpArchive) . " -C " . escapeshellarg($tmpDbDir) . " database.sqlite";
|
||||
if (is_dir('/var/lib/asterisk/sounds/custom')) {
|
||||
$cmd .= " -C /var/lib/asterisk/sounds custom";
|
||||
}
|
||||
if (is_dir('/var/spool/asterisk/voicemail')) {
|
||||
$cmd .= " -C /var/spool/asterisk voicemail";
|
||||
}
|
||||
if ($hasAstdb) {
|
||||
$cmd .= " -C /tmp astdb_cluster_sync.sqlite3";
|
||||
}
|
||||
shell_exec($cmd);
|
||||
$cmd = "tar -czf " . escapeshellarg($tmpArchive) . " -C " . escapeshellarg($tmpDbDir) . " database.sqlite";
|
||||
if (is_dir('/var/lib/asterisk/sounds/custom')) {
|
||||
$cmd .= " -C /var/lib/asterisk/sounds custom";
|
||||
}
|
||||
if (is_dir('/var/spool/asterisk/voicemail')) {
|
||||
$cmd .= " -C /var/spool/asterisk voicemail";
|
||||
}
|
||||
if (is_dir('/var/www/nexus-pbx/public/avatars')) {
|
||||
$cmd .= " -C /var/www/nexus-pbx/public avatars";
|
||||
}
|
||||
if ($hasAstdb) {
|
||||
$cmd .= " -C /tmp " . escapeshellarg(basename($tmpAstdb));
|
||||
}
|
||||
exec($cmd . " 2>&1", $tarOut, $tarCode);
|
||||
|
||||
@unlink($tmpDbDir . '/database.sqlite');
|
||||
@rmdir($tmpDbDir);
|
||||
if ($hasAstdb) {
|
||||
if (!file_exists($tmpArchive) || filesize($tmpArchive) === 0 || $tarCode >= 2) {
|
||||
throw new RuntimeException("Échec de génération de l'archive de réplication (tar code {$tarCode}).");
|
||||
}
|
||||
|
||||
$archiveData = file_get_contents($tmpArchive);
|
||||
} finally {
|
||||
@unlink($tmpDbDump);
|
||||
@unlink($tmpAstdb);
|
||||
}
|
||||
|
||||
if (!file_exists($tmpArchive) || filesize($tmpArchive) === 0) {
|
||||
@unlink($tmpArchive);
|
||||
throw new RuntimeException("Échec de génération de l'archive de réplication.");
|
||||
shell_exec('rm -rf ' . escapeshellarg($tmpDbDir) . ' 2>/dev/null || true');
|
||||
}
|
||||
|
||||
$archiveData = file_get_contents($tmpArchive);
|
||||
@unlink($tmpArchive);
|
||||
|
||||
// Collecte des adresses IP bannies dans Fail2ban (jails actives)
|
||||
$bannedIps = $this->getActiveFail2banBans();
|
||||
|
||||
@@ -629,6 +649,9 @@ class ClusterManager
|
||||
$resJson = json_decode((string)$response, true);
|
||||
$detail = !empty($resJson['message']) ? " - " . $resJson['message'] : ($err ? " : {$err}" : "");
|
||||
$lastError = "Échec d'envoi vers {$targetUrl} (HTTP {$httpCode}){$detail}";
|
||||
if (in_array($httpCode, [400, 401, 403, 422], true) && !empty($resJson['message'])) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -668,94 +691,113 @@ class ClusterManager
|
||||
throw new RuntimeException("Paquet de réplication vide ou corrompu.");
|
||||
}
|
||||
|
||||
$archiveRaw = base64_decode($data['archive_b64']);
|
||||
$tmpArchive = '/tmp/nexus_cluster_recv_' . time() . '.tar.gz';
|
||||
file_put_contents($tmpArchive, $archiveRaw);
|
||||
|
||||
// Extraction sécurisée dans un dossier temporaire pour validation préalable
|
||||
$tmpExtractDir = '/tmp/nexus_cluster_extract_' . time();
|
||||
@mkdir($tmpExtractDir, 0755, true);
|
||||
shell_exec("tar -xzf " . escapeshellarg($tmpArchive) . " -C " . escapeshellarg($tmpExtractDir) . " 2>/dev/null || true");
|
||||
|
||||
$extractedDb = $tmpExtractDir . '/database.sqlite';
|
||||
if (!file_exists($extractedDb) || filesize($extractedDb) === 0) {
|
||||
@unlink($tmpArchive);
|
||||
@rmdir($tmpExtractDir);
|
||||
throw new RuntimeException("L'archive de réplication reçue ne contient pas de base de données valide.");
|
||||
$this->cleanupStaleClusterTempFiles();
|
||||
$uniqRecv = getmypid() . '_' . bin2hex(random_bytes(4));
|
||||
$archiveRaw = base64_decode($data['archive_b64'], true);
|
||||
if ($archiveRaw === false || $archiveRaw === '') {
|
||||
throw new RuntimeException("Archive de réplication invalide (décodage base64).");
|
||||
}
|
||||
|
||||
// Contrôle d'intégrité SQLite strict du fichier reçu AVANT toute modification du système local
|
||||
$check = trim(shell_exec("sqlite3 " . escapeshellarg($extractedDb) . " 'PRAGMA integrity_check;' 2>/dev/null") ?? '');
|
||||
if ($check !== 'ok') {
|
||||
@unlink($extractedDb);
|
||||
@rmdir($tmpExtractDir);
|
||||
@unlink($tmpArchive);
|
||||
throw new RuntimeException("La base de données reçue est corrompue ({$check}) : rejet strict de l'application.");
|
||||
}
|
||||
|
||||
// Sauvegarde d'urgence de la base actuelle avant synchronisation
|
||||
$dbPath = Database::getDatabasePath();
|
||||
if (file_exists($dbPath)) {
|
||||
@copy($dbPath, $dbPath . '.pre_cluster_sync.bak');
|
||||
}
|
||||
|
||||
// Sauvegarder la configuration locale cluster et réseau pour ne pas l'écraser par celle du master
|
||||
$tmpArchive = '/tmp/nexus_cluster_recv_' . $uniqRecv . '.tar.gz';
|
||||
$tmpExtractDir = '/tmp/nexus_cluster_extract_' . $uniqRecv;
|
||||
$localClusterSettings = $settings;
|
||||
$localSettingsRow = null;
|
||||
|
||||
try {
|
||||
$localSettingsRow = $this->db->query("SELECT local_net, local_networks_json, admin_https_port FROM settings WHERE id = 1")->fetch(PDO::FETCH_ASSOC);
|
||||
} catch (\Throwable $e) {}
|
||||
|
||||
// Restauration officielle en ligne via l'API SQLite .restore
|
||||
// Ne détruit aucun descripteur de fichier, ne touche pas aux journaux WAL/SHM,
|
||||
// et n'invalide aucune lecture concurrente dans PHP-FPM.
|
||||
if (file_exists($dbPath)) {
|
||||
$restoreCmd = "sqlite3 " . escapeshellarg($dbPath) . " \".restore " . escapeshellarg($extractedDb) . "\" 2>&1";
|
||||
$restoreOut = shell_exec($restoreCmd);
|
||||
if (!empty($restoreOut)) {
|
||||
error_log("[CLUSTER] Avertissement sqlite3 .restore : " . trim($restoreOut));
|
||||
$written = @file_put_contents($tmpArchive, $archiveRaw);
|
||||
if ($written === false || $written !== strlen($archiveRaw)) {
|
||||
throw new RuntimeException("Espace insuffisant dans /tmp lors de l'écriture de l'archive HA.");
|
||||
}
|
||||
} else {
|
||||
@copy($extractedDb, $dbPath);
|
||||
@chmod($dbPath, 0660);
|
||||
@chown($dbPath, 'www-data');
|
||||
@chgrp($dbPath, 'asterisk');
|
||||
}
|
||||
|
||||
@unlink($extractedDb);
|
||||
// Extraction sécurisée dans un dossier temporaire pour validation préalable
|
||||
@mkdir($tmpExtractDir, 0755, true);
|
||||
exec("tar -xzf " . escapeshellarg($tmpArchive) . " -C " . escapeshellarg($tmpExtractDir) . " 2>&1", $extOut, $extCode);
|
||||
|
||||
// Contrôle d'intégrité immédiat après synchronisation
|
||||
$postCheck = trim(shell_exec("sqlite3 " . escapeshellarg($dbPath) . " 'PRAGMA integrity_check;' 2>/dev/null") ?? '');
|
||||
if ($postCheck !== 'ok') {
|
||||
if (file_exists($dbPath . '.pre_cluster_sync.bak')) {
|
||||
shell_exec("sqlite3 " . escapeshellarg($dbPath) . " \".restore " . escapeshellarg($dbPath . '.pre_cluster_sync.bak') . "\" 2>/dev/null || true");
|
||||
$extractedDb = $tmpExtractDir . '/database.sqlite';
|
||||
if (!file_exists($extractedDb) || filesize($extractedDb) === 0) {
|
||||
$detail = !empty($extOut) ? ' (' . implode(' | ', array_slice($extOut, 0, 2)) . ')' : '';
|
||||
throw new RuntimeException("L'archive de réplication reçue ne contient pas de base de données valide{$detail}.");
|
||||
}
|
||||
throw new RuntimeException("Échec de vérification d'intégrité après synchronisation ({$postCheck}). Restauration préventive effectuée.");
|
||||
}
|
||||
|
||||
// Synchronisation miroir des sons personnalisés (avec suppression des sons effacés)
|
||||
$extractedCustom = $tmpExtractDir . '/custom';
|
||||
if (is_dir($extractedCustom) && is_dir('/var/lib/asterisk/sounds')) {
|
||||
shell_exec("rsync -a --delete " . escapeshellarg($extractedCustom . '/') . " /var/lib/asterisk/sounds/custom/ 2>/dev/null || true");
|
||||
shell_exec("chown -R asterisk:asterisk /var/lib/asterisk/sounds/custom 2>/dev/null || true");
|
||||
}
|
||||
// Contrôle d'intégrité SQLite strict du fichier reçu AVANT toute modification du système local
|
||||
$check = $this->checkSqliteIntegrity($extractedDb);
|
||||
if ($check !== 'ok') {
|
||||
throw new RuntimeException("La base de données reçue est corrompue ({$check}) : rejet strict de l'application.");
|
||||
}
|
||||
|
||||
// Synchronisation miroir de la messagerie vocale (avec suppression des messages effacés)
|
||||
$extractedVm = $tmpExtractDir . '/voicemail';
|
||||
if (is_dir($extractedVm) && is_dir('/var/spool/asterisk/voicemail')) {
|
||||
shell_exec("rsync -a --delete " . escapeshellarg($extractedVm . '/') . " /var/spool/asterisk/voicemail/ 2>/dev/null || true");
|
||||
shell_exec("chown -R asterisk:asterisk /var/spool/asterisk/voicemail 2>/dev/null || true");
|
||||
}
|
||||
// Sauvegarde d'urgence de la base actuelle avant synchronisation
|
||||
$dbPath = Database::getDatabasePath();
|
||||
if (file_exists($dbPath)) {
|
||||
@copy($dbPath, $dbPath . '.pre_cluster_sync.bak');
|
||||
}
|
||||
|
||||
// Restauration de l'astdb (contacts et IP actives des téléphones PJSIP) via le helper root
|
||||
$extractedAstdb = $tmpExtractDir . '/astdb_cluster_sync.sqlite3';
|
||||
if (file_exists($extractedAstdb)) {
|
||||
shell_exec('sudo -n ' . escapeshellcmd(self::CLUSTER_HELPER_PATH) . ' restore-astdb ' . escapeshellarg($extractedAstdb) . ' 2>&1');
|
||||
@unlink($extractedAstdb);
|
||||
}
|
||||
// Sauvegarder la configuration locale cluster et réseau pour ne pas l'écraser par celle du master
|
||||
try {
|
||||
$localSettingsRow = $this->db->query("SELECT local_net, local_networks_json, admin_https_port FROM settings WHERE id = 1")->fetch(PDO::FETCH_ASSOC);
|
||||
} catch (\Throwable $e) {}
|
||||
|
||||
@rmdir($tmpExtractDir);
|
||||
@unlink($tmpArchive);
|
||||
// Restauration officielle en ligne via l'API SQLite (SQLite3::backup ou sqlite3 .restore)
|
||||
if (file_exists($dbPath)) {
|
||||
$this->restoreSqliteDatabase($extractedDb, $dbPath);
|
||||
} else {
|
||||
@copy($extractedDb, $dbPath);
|
||||
@chmod($dbPath, 0660);
|
||||
@chown($dbPath, 'www-data');
|
||||
@chgrp($dbPath, 'asterisk');
|
||||
}
|
||||
|
||||
@unlink($extractedDb);
|
||||
|
||||
// Contrôle d'intégrité immédiat après synchronisation
|
||||
$postCheck = $this->checkSqliteIntegrity($dbPath);
|
||||
if ($postCheck !== 'ok') {
|
||||
if (file_exists($dbPath . '.pre_cluster_sync.bak')) {
|
||||
$this->restoreSqliteDatabase($dbPath . '.pre_cluster_sync.bak', $dbPath);
|
||||
}
|
||||
throw new RuntimeException("Échec de vérification d'intégrité après synchronisation ({$postCheck}). Restauration préventive effectuée.");
|
||||
}
|
||||
|
||||
// Synchronisation miroir des sons personnalisés (avec suppression des sons effacés)
|
||||
$extractedCustom = $tmpExtractDir . '/custom';
|
||||
if (is_dir($extractedCustom) && is_dir('/var/lib/asterisk/sounds')) {
|
||||
shell_exec("rsync -a --delete " . escapeshellarg($extractedCustom . '/') . " /var/lib/asterisk/sounds/custom/ 2>/dev/null || true");
|
||||
shell_exec("chown -R asterisk:asterisk /var/lib/asterisk/sounds/custom 2>/dev/null || true");
|
||||
}
|
||||
|
||||
// Synchronisation miroir de la messagerie vocale (avec suppression des messages effacés)
|
||||
$extractedVm = $tmpExtractDir . '/voicemail';
|
||||
if (is_dir($extractedVm) && is_dir('/var/spool/asterisk/voicemail')) {
|
||||
shell_exec("rsync -a --delete " . escapeshellarg($extractedVm . '/') . " /var/spool/asterisk/voicemail/ 2>/dev/null || true");
|
||||
shell_exec("chown -R asterisk:asterisk /var/spool/asterisk/voicemail 2>/dev/null || true");
|
||||
}
|
||||
|
||||
// Synchronisation miroir des avatars de l'annuaire / UCP
|
||||
$extractedAvatars = $tmpExtractDir . '/avatars';
|
||||
if (is_dir($extractedAvatars)) {
|
||||
if (!is_dir('/var/www/nexus-pbx/public/avatars')) {
|
||||
@mkdir('/var/www/nexus-pbx/public/avatars', 0775, true);
|
||||
}
|
||||
shell_exec("rsync -a --delete " . escapeshellarg($extractedAvatars . '/') . " /var/www/nexus-pbx/public/avatars/ 2>/dev/null || true");
|
||||
shell_exec("chown -R www-data:asterisk /var/www/nexus-pbx/public/avatars 2>/dev/null || true");
|
||||
shell_exec("chmod -R 775 /var/www/nexus-pbx/public/avatars 2>/dev/null || true");
|
||||
}
|
||||
|
||||
// Restauration de l'astdb (contacts et IP actives des téléphones PJSIP) via le helper root
|
||||
$extractedAstdb = $tmpExtractDir . '/astdb_cluster_sync_' . $uniqRecv . '.sqlite3';
|
||||
if (!file_exists($extractedAstdb)) {
|
||||
$candidates = glob($tmpExtractDir . '/astdb_cluster_sync*.sqlite3') ?: [];
|
||||
if (!empty($candidates)) {
|
||||
$extractedAstdb = $candidates[0];
|
||||
}
|
||||
}
|
||||
if (file_exists($extractedAstdb)) {
|
||||
shell_exec('sudo -n ' . escapeshellcmd(self::CLUSTER_HELPER_PATH) . ' restore-astdb ' . escapeshellarg($extractedAstdb) . ' 2>&1');
|
||||
@unlink($extractedAstdb);
|
||||
}
|
||||
} finally {
|
||||
shell_exec('rm -rf ' . escapeshellarg($tmpExtractDir) . ' 2>/dev/null || true');
|
||||
@unlink($tmpArchive);
|
||||
}
|
||||
|
||||
// Réouverture de la connexion DB fraîche
|
||||
$newDb = Database::getConnection(true);
|
||||
@@ -809,6 +851,17 @@ class ClusterManager
|
||||
// Régénérer les configurations PJSIP et Asterisk
|
||||
ConfigGenerator::generateAndApply($newDb);
|
||||
|
||||
// Synchroniser la configuration Nginx/Fail2ban locale (dont ports et jail UCP répliqués)
|
||||
try {
|
||||
if (function_exists('posix_geteuid') && posix_geteuid() !== 0) {
|
||||
@exec('sudo -n /var/www/nexus-pbx/scripts/nexus-firewall init-ucp >/dev/null 2>&1');
|
||||
} else {
|
||||
FirewallManager::saveSettings(FirewallManager::getSettings());
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Non-bloquant lors de la synchronisation HA
|
||||
}
|
||||
|
||||
// Rechargement à chaud d'Asterisk pour appliquer immédiatement la configuration en RAM
|
||||
try {
|
||||
$ami = AmiClient::fromDatabase($newDb);
|
||||
@@ -1067,6 +1120,79 @@ class ClusterManager
|
||||
];
|
||||
}
|
||||
|
||||
private function cleanupStaleClusterTempFiles(): void
|
||||
{
|
||||
$cutoff = time() - 600;
|
||||
$patterns = [
|
||||
'/tmp/nexus_cluster_extract_*',
|
||||
'/tmp/nexus_cluster_tar_*',
|
||||
'/tmp/nexus_cluster_recv_*',
|
||||
'/tmp/nexus_cluster_sync_*',
|
||||
'/tmp/nexus_cluster_db_*',
|
||||
'/tmp/astdb_cluster_sync*',
|
||||
];
|
||||
foreach ($patterns as $pattern) {
|
||||
foreach (glob($pattern) ?: [] as $path) {
|
||||
$mtime = @filemtime($path);
|
||||
if ($mtime !== false && $mtime < $cutoff) {
|
||||
if (is_dir($path)) {
|
||||
shell_exec('rm -rf ' . escapeshellarg($path) . ' 2>/dev/null || true');
|
||||
} else {
|
||||
@unlink($path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function checkSqliteIntegrity(string $filePath): string
|
||||
{
|
||||
if (!file_exists($filePath) || filesize($filePath) === 0) {
|
||||
return 'fichier manquant ou vide';
|
||||
}
|
||||
|
||||
try {
|
||||
$pdo = new PDO('sqlite:' . $filePath);
|
||||
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
$res = trim((string)$pdo->query('PRAGMA integrity_check;')->fetchColumn());
|
||||
$pdo = null;
|
||||
if ($res !== '') {
|
||||
return $res;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$cliOut = trim((string)shell_exec('sqlite3 ' . escapeshellarg($filePath) . " 'PRAGMA integrity_check;' 2>&1"));
|
||||
return $cliOut !== '' ? $cliOut : $e->getMessage();
|
||||
}
|
||||
|
||||
$cliOut = trim((string)shell_exec('sqlite3 ' . escapeshellarg($filePath) . " 'PRAGMA integrity_check;' 2>&1"));
|
||||
return $cliOut !== '' ? $cliOut : 'réponse integrity_check vide';
|
||||
}
|
||||
|
||||
private function restoreSqliteDatabase(string $sourceFile, string $targetFile): void
|
||||
{
|
||||
if (class_exists(\SQLite3::class)) {
|
||||
try {
|
||||
$src = new \SQLite3($sourceFile, SQLITE3_OPEN_READONLY);
|
||||
$dst = new \SQLite3($targetFile);
|
||||
$dst->busyTimeout(5000);
|
||||
$ok = $src->backup($dst);
|
||||
$src->close();
|
||||
$dst->close();
|
||||
if ($ok) {
|
||||
return;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
error_log('[CLUSTER] Avertissement SQLite3::backup : ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
$restoreCmd = 'sqlite3 ' . escapeshellarg($targetFile) . ' ".restore ' . escapeshellarg($sourceFile) . '" 2>&1';
|
||||
$restoreOut = shell_exec($restoreCmd);
|
||||
if (!empty($restoreOut)) {
|
||||
error_log('[CLUSTER] Avertissement sqlite3 .restore : ' . trim((string)$restoreOut));
|
||||
}
|
||||
}
|
||||
|
||||
private function updateSyncStatus(string $status, string $message): void
|
||||
{
|
||||
$stmt = $this->db->prepare('
|
||||
|
||||
@@ -987,9 +987,9 @@ class ConfigGenerator
|
||||
$lines[] = " same => n,Hangup()";
|
||||
}
|
||||
$lines[] = " same => n(cf_{$num}),NoOp(Poste {$num} renvoi inconditionnel vers \${DB(CF/{$num})})";
|
||||
$lines[] = " same => n,Goto(from-internal,\${DB(CF/{$num})},1)";
|
||||
$lines[] = " same => n,GotoIf(\$[\"\${DB(CF/{$num}):0:3}\" = \"vm-\"]?app-voicemail,\${DB(CF/{$num}):3},1:from-internal,\${DB(CF/{$num})},1)";
|
||||
$lines[] = " same => n(cfu_{$num}),NoOp(Poste {$num} indisponible, renvoi vers \${DB(CFU/{$num})})";
|
||||
$lines[] = " same => n,Goto(from-internal,\${DB(CFU/{$num})},1)";
|
||||
$lines[] = " same => n,GotoIf(\$[\"\${DB(CFU/{$num}):0:3}\" = \"vm-\"]?app-voicemail,\${DB(CFU/{$num}):3},1:from-internal,\${DB(CFU/{$num})},1)";
|
||||
$lines[] = "";
|
||||
}
|
||||
|
||||
|
||||
+36
-1
@@ -48,7 +48,9 @@ class Database
|
||||
// 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");
|
||||
self::$instance->query("SELECT ucp_https_port, fail2ban_ucp_maxretry, groundwire_secret, ucp_logo FROM settings LIMIT 1");
|
||||
self::$instance->query("SELECT groundwire_enabled FROM extensions LIMIT 1");
|
||||
self::$instance->query("SELECT id FROM ucp_users LIMIT 1");
|
||||
} catch (\Throwable $e) {
|
||||
self::initializeSchema();
|
||||
}
|
||||
@@ -116,6 +118,7 @@ class Database
|
||||
encryption INTEGER NOT NULL DEFAULT 0 CHECK (encryption IN (0, 1, 2)),
|
||||
codecs VARCHAR(64) NOT NULL DEFAULT 'alaw,ulaw,opus',
|
||||
max_contacts INTEGER NOT NULL DEFAULT 2,
|
||||
groundwire_enabled INTEGER NOT NULL DEFAULT 0 CHECK (groundwire_enabled IN (0, 1)),
|
||||
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
@@ -338,6 +341,21 @@ class Database
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 15. Table ucp_users : Utilisateurs du portail UCP (isolé de l'Admin)
|
||||
CREATE TABLE IF NOT EXISTS ucp_users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username VARCHAR(64) NOT NULL UNIQUE,
|
||||
display_name VARCHAR(128) NOT NULL DEFAULT '',
|
||||
email VARCHAR(128) NOT NULL DEFAULT '',
|
||||
password_hash TEXT NOT NULL,
|
||||
extensions_json TEXT NOT NULL DEFAULT '[]',
|
||||
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
|
||||
last_login_at DATETIME DEFAULT NULL,
|
||||
last_login_ip VARCHAR(64) NOT NULL DEFAULT '',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
SQL;
|
||||
|
||||
$db->exec($schema);
|
||||
@@ -377,6 +395,9 @@ class Database
|
||||
if (!in_array('company_logo', $existing, true)) {
|
||||
$db->exec("ALTER TABLE settings ADD COLUMN company_logo TEXT NOT NULL DEFAULT ''");
|
||||
}
|
||||
if (!in_array('ucp_logo', $existing, true)) {
|
||||
$db->exec("ALTER TABLE settings ADD COLUMN ucp_logo TEXT NOT NULL DEFAULT ''");
|
||||
}
|
||||
if (!in_array('default_moh_sound_id', $existing, true)) {
|
||||
$db->exec("ALTER TABLE settings ADD COLUMN default_moh_sound_id INTEGER DEFAULT 2");
|
||||
}
|
||||
@@ -418,12 +439,25 @@ class Database
|
||||
'fail2ban_web_maxretry' => "INTEGER NOT NULL DEFAULT 3",
|
||||
'fail2ban_web_bantime' => "INTEGER NOT NULL DEFAULT 3600",
|
||||
'fail2ban_whitelist_ips' => "TEXT NOT NULL DEFAULT '127.0.0.1/8 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 82.122.84.60'",
|
||||
'ucp_enabled' => "INTEGER NOT NULL DEFAULT 1",
|
||||
'ucp_https_port' => "INTEGER NOT NULL DEFAULT 8444",
|
||||
'ucp_http_port' => "INTEGER NOT NULL DEFAULT 8084",
|
||||
'fail2ban_ucp_maxretry' => "INTEGER NOT NULL DEFAULT 3",
|
||||
'fail2ban_ucp_bantime' => "INTEGER NOT NULL DEFAULT 3600",
|
||||
'groundwire_secret' => "VARCHAR(128) NOT NULL DEFAULT ''",
|
||||
];
|
||||
foreach ($extraSettings as $colName => $colDef) {
|
||||
if (!in_array($colName, $existing, true)) {
|
||||
$db->exec("ALTER TABLE settings ADD COLUMN {$colName} {$colDef}");
|
||||
}
|
||||
}
|
||||
|
||||
// Clé HMAC persistante en base (partagée entre nœuds HA) pour les liens XML GroundWire
|
||||
$gwSecret = (string)($db->query('SELECT groundwire_secret FROM settings WHERE id = 1')->fetchColumn() ?: '');
|
||||
if ($gwSecret === '') {
|
||||
$newSecret = bin2hex(random_bytes(32));
|
||||
$db->prepare('UPDATE settings SET groundwire_secret = :s WHERE id = 1')->execute([':s' => $newSecret]);
|
||||
}
|
||||
}
|
||||
|
||||
private static function migrateExtensionsTable(PDO $db): void
|
||||
@@ -466,6 +500,7 @@ class Database
|
||||
'voicemail_attach' => "INTEGER NOT NULL DEFAULT 1",
|
||||
'voicemail_delete' => "INTEGER NOT NULL DEFAULT 0",
|
||||
'ring_time' => "INTEGER NOT NULL DEFAULT 20",
|
||||
'groundwire_enabled' => "INTEGER NOT NULL DEFAULT 0",
|
||||
];
|
||||
foreach ($extraExtensionCols as $colName => $colDef) {
|
||||
if (!in_array($colName, $existing, true)) {
|
||||
|
||||
+338
-26
@@ -13,6 +13,9 @@ class FirewallManager
|
||||
public const JAIL_LOCAL_PATH = '/etc/fail2ban/jail.local';
|
||||
public const NGINX_CONF_PATH = '/etc/nginx/sites-available/nexus-pbx';
|
||||
public const WEB_FILTER_PATH = '/etc/fail2ban/filter.d/nexus-web.conf';
|
||||
public const UCP_FILTER_PATH = '/etc/fail2ban/filter.d/nexus-ucp.conf';
|
||||
public const UCP_SECURITY_LOG = '/var/log/nexus_ucp_security.log';
|
||||
public const UCP_ROOT_DIR = '/var/www/nexus-pbx/ucp';
|
||||
|
||||
/**
|
||||
* Récupère la configuration actuelle du pare-feu et de Fail2ban depuis SQLite.
|
||||
@@ -23,7 +26,9 @@ class FirewallManager
|
||||
$stmt = $db->query("SELECT
|
||||
firewall_enabled, admin_https_port, certbot_strict_port80,
|
||||
fail2ban_sip_maxretry, fail2ban_sip_findtime, fail2ban_sip_bantime,
|
||||
fail2ban_web_maxretry, fail2ban_web_bantime, fail2ban_whitelist_ips
|
||||
fail2ban_web_maxretry, fail2ban_web_bantime, fail2ban_whitelist_ips,
|
||||
ucp_enabled, ucp_https_port, ucp_http_port,
|
||||
fail2ban_ucp_maxretry, fail2ban_ucp_bantime
|
||||
FROM settings WHERE id = 1");
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
@@ -38,6 +43,11 @@ class FirewallManager
|
||||
'fail2ban_web_maxretry' => 3,
|
||||
'fail2ban_web_bantime' => 3600,
|
||||
'fail2ban_whitelist_ips' => '127.0.0.1/8 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 82.122.84.60',
|
||||
'ucp_enabled' => 1,
|
||||
'ucp_https_port' => 8444,
|
||||
'ucp_http_port' => 8084,
|
||||
'fail2ban_ucp_maxretry' => 3,
|
||||
'fail2ban_ucp_bantime' => 3600,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -51,6 +61,11 @@ class FirewallManager
|
||||
'fail2ban_web_maxretry' => (int)($row['fail2ban_web_maxretry'] ?? 3),
|
||||
'fail2ban_web_bantime' => (int)($row['fail2ban_web_bantime'] ?? 3600),
|
||||
'fail2ban_whitelist_ips' => (string)($row['fail2ban_whitelist_ips'] ?? '127.0.0.1/8 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 82.122.84.60'),
|
||||
'ucp_enabled' => (int)($row['ucp_enabled'] ?? 1),
|
||||
'ucp_https_port' => (int)($row['ucp_https_port'] ?? 8444),
|
||||
'ucp_http_port' => (int)($row['ucp_http_port'] ?? 8084),
|
||||
'fail2ban_ucp_maxretry' => (int)($row['fail2ban_ucp_maxretry'] ?? 3),
|
||||
'fail2ban_ucp_bantime' => (int)($row['fail2ban_ucp_bantime'] ?? 3600),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -111,11 +126,8 @@ class FirewallManager
|
||||
}
|
||||
}
|
||||
|
||||
// IP actuelle du client
|
||||
$currentIp = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1';
|
||||
if (str_contains($currentIp, ',')) {
|
||||
$currentIp = trim(explode(',', $currentIp)[0]);
|
||||
}
|
||||
// IP actuelle du client (avec résolution reverse proxy si proxy privé/local)
|
||||
$currentIp = self::resolveClientIp();
|
||||
|
||||
$status = [
|
||||
'is_running' => $isRunning,
|
||||
@@ -151,7 +163,7 @@ class FirewallManager
|
||||
}
|
||||
} else {
|
||||
// Débannir de toutes les prisons connues
|
||||
foreach (['asterisk', 'nexus-web', 'sshd'] as $j) {
|
||||
foreach (['asterisk', 'nexus-web', 'nexus-ucp', 'sshd'] as $j) {
|
||||
$cmd = "timeout 2 sudo -n /usr/bin/fail2ban-client set " . escapeshellarg($j) . " unbanip " . escapeshellarg($ip) . " 2>&1";
|
||||
exec($cmd, $out, $code);
|
||||
if ($code === 0) {
|
||||
@@ -200,6 +212,7 @@ class FirewallManager
|
||||
{
|
||||
$db = Database::getConnection();
|
||||
|
||||
$oldSettings = self::getSettings();
|
||||
$strictPort80 = !empty($data['certbot_strict_port80']) ? 1 : 0;
|
||||
$firewallEnabled = isset($data['firewall_enabled']) ? (!empty($data['firewall_enabled']) ? 1 : 0) : 1;
|
||||
$adminPort = max(1, min(65535, (int)($data['admin_https_port'] ?? 8443)));
|
||||
@@ -209,6 +222,26 @@ class FirewallManager
|
||||
$webMaxRetry = max(1, min(20, (int)($data['fail2ban_web_maxretry'] ?? 3)));
|
||||
$webBanTime = max(60, min(31536000, (int)($data['fail2ban_web_bantime'] ?? 3600)));
|
||||
|
||||
$ucpEnabled = isset($data['ucp_enabled']) ? (!empty($data['ucp_enabled']) ? 1 : 0) : (int)$oldSettings['ucp_enabled'];
|
||||
$ucpHttpsPort = isset($data['ucp_https_port']) ? max(0, min(65535, (int)$data['ucp_https_port'])) : (int)$oldSettings['ucp_https_port'];
|
||||
$ucpHttpPort = isset($data['ucp_http_port']) ? max(0, min(65535, (int)$data['ucp_http_port'])) : (int)$oldSettings['ucp_http_port'];
|
||||
$ucpMaxRetry = isset($data['fail2ban_ucp_maxretry']) ? max(1, min(20, (int)$data['fail2ban_ucp_maxretry'])) : (int)$oldSettings['fail2ban_ucp_maxretry'];
|
||||
$ucpBanTime = isset($data['fail2ban_ucp_bantime']) ? max(60, min(31536000, (int)$data['fail2ban_ucp_bantime'])) : (int)$oldSettings['fail2ban_ucp_bantime'];
|
||||
|
||||
if ($ucpEnabled === 1 && $ucpHttpsPort === 0 && $ucpHttpPort === 0) {
|
||||
throw new InvalidArgumentException("Lorsque l'UCP est activé, au moins un port (HTTPS ou HTTP) doit être supérieur à 0.");
|
||||
}
|
||||
$reservedPorts = [80, 443, 8080, $adminPort, 5060, 5061];
|
||||
if ($ucpHttpsPort > 0 && in_array($ucpHttpsPort, $reservedPorts, true)) {
|
||||
throw new InvalidArgumentException("Le port HTTPS UCP ({$ucpHttpsPort}) entre en conflit avec un port réservé (Admin/SIP/ACME).");
|
||||
}
|
||||
if ($ucpHttpPort > 0 && in_array($ucpHttpPort, $reservedPorts, true)) {
|
||||
throw new InvalidArgumentException("Le port HTTP UCP ({$ucpHttpPort}) entre en conflit avec un port réservé (Admin/SIP/ACME).");
|
||||
}
|
||||
if ($ucpHttpsPort > 0 && $ucpHttpPort > 0 && $ucpHttpsPort === $ucpHttpPort) {
|
||||
throw new InvalidArgumentException("Les ports HTTPS et HTTP de l'UCP doivent être différents.");
|
||||
}
|
||||
|
||||
$whitelist = trim((string)($data['fail2ban_whitelist_ips'] ?? ''));
|
||||
$cleanTokens = array_values(array_filter(preg_split('/\s+/', $whitelist)));
|
||||
|
||||
@@ -233,22 +266,33 @@ class FirewallManager
|
||||
fail2ban_web_maxretry = :wmr,
|
||||
fail2ban_web_bantime = :wbt,
|
||||
fail2ban_whitelist_ips = :wl,
|
||||
ucp_enabled = :ue,
|
||||
ucp_https_port = :uhs,
|
||||
ucp_http_port = :uhp,
|
||||
fail2ban_ucp_maxretry = :umr,
|
||||
fail2ban_ucp_bantime = :ubt,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = 1");
|
||||
|
||||
$stmt->execute([
|
||||
':fe' => $firewallEnabled,
|
||||
':ap' => $adminPort,
|
||||
':fe' => $firewallEnabled,
|
||||
':ap' => $adminPort,
|
||||
':sp80' => $strictPort80,
|
||||
':smr' => $sipMaxRetry,
|
||||
':sft' => $sipFindTime,
|
||||
':sbt' => $sipBanTime,
|
||||
':wmr' => $webMaxRetry,
|
||||
':wbt' => $webBanTime,
|
||||
':wl' => $finalWhitelist,
|
||||
':smr' => $sipMaxRetry,
|
||||
':sft' => $sipFindTime,
|
||||
':sbt' => $sipBanTime,
|
||||
':wmr' => $webMaxRetry,
|
||||
':wbt' => $webBanTime,
|
||||
':wl' => $finalWhitelist,
|
||||
':ue' => $ucpEnabled,
|
||||
':uhs' => $ucpHttpsPort,
|
||||
':uhp' => $ucpHttpPort,
|
||||
':umr' => $ucpMaxRetry,
|
||||
':ubt' => $ucpBanTime,
|
||||
]);
|
||||
|
||||
// 2. Génération et application du fichier /etc/fail2ban/jail.local
|
||||
// 2. Garantir la présence du filtre & journal Fail2ban UCP puis générer /etc/fail2ban/jail.local
|
||||
self::ensureUcpFail2banArtifacts();
|
||||
self::generateJailLocal(
|
||||
$finalWhitelist,
|
||||
$sipMaxRetry,
|
||||
@@ -256,7 +300,12 @@ class FirewallManager
|
||||
$sipBanTime,
|
||||
$webMaxRetry,
|
||||
$webBanTime,
|
||||
$adminPort
|
||||
$adminPort,
|
||||
$ucpEnabled,
|
||||
$ucpHttpsPort,
|
||||
$ucpHttpPort,
|
||||
$ucpMaxRetry,
|
||||
$ucpBanTime
|
||||
);
|
||||
|
||||
// 3. Rechargement / Redémarrage propre de Fail2ban
|
||||
@@ -265,14 +314,19 @@ class FirewallManager
|
||||
exec('timeout 5 sudo -n /usr/bin/systemctl restart fail2ban 2>&1');
|
||||
}
|
||||
|
||||
// 4. Mise à jour de la configuration Nginx
|
||||
self::applyNginxConfig($adminPort, $strictPort80);
|
||||
// 4. Mise à jour de la configuration Nginx & ouverture/fermeture dynamique des ports UCP
|
||||
self::applyNginxConfig($adminPort, $strictPort80, $ucpEnabled, $ucpHttpsPort, $ucpHttpPort);
|
||||
self::syncUcpFirewallPorts($oldSettings, [
|
||||
'ucp_enabled' => $ucpEnabled,
|
||||
'ucp_https_port' => $ucpHttpsPort,
|
||||
'ucp_http_port' => $ucpHttpPort,
|
||||
]);
|
||||
|
||||
@unlink('/tmp/nexus_fw_status_cache.json');
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => 'Configuration Pare-feu & Fail2ban appliquée avec succès ! Nginx et Fail2ban rechargés.',
|
||||
'message' => 'Configuration Pare-feu, UCP & Fail2ban appliquée avec succès ! Nginx et Fail2ban rechargés.',
|
||||
'settings' => self::getSettings(),
|
||||
];
|
||||
}
|
||||
@@ -287,9 +341,30 @@ class FirewallManager
|
||||
int $sipBanTime,
|
||||
int $webMaxRetry,
|
||||
int $webBanTime,
|
||||
int $adminPort
|
||||
int $adminPort,
|
||||
?int $ucpEnabled = null,
|
||||
?int $ucpHttpsPort = null,
|
||||
?int $ucpHttpPort = null,
|
||||
?int $ucpMaxRetry = null,
|
||||
?int $ucpBanTime = null
|
||||
): void {
|
||||
if ($ucpEnabled === null || $ucpHttpsPort === null || $ucpHttpPort === null || $ucpMaxRetry === null || $ucpBanTime === null) {
|
||||
$st = self::getSettings();
|
||||
$ucpEnabled = $ucpEnabled ?? $st['ucp_enabled'];
|
||||
$ucpHttpsPort = $ucpHttpsPort ?? $st['ucp_https_port'];
|
||||
$ucpHttpPort = $ucpHttpPort ?? $st['ucp_http_port'];
|
||||
$ucpMaxRetry = $ucpMaxRetry ?? $st['fail2ban_ucp_maxretry'];
|
||||
$ucpBanTime = $ucpBanTime ?? $st['fail2ban_ucp_bantime'];
|
||||
}
|
||||
|
||||
self::ensureUcpFail2banArtifacts();
|
||||
|
||||
$portsWeb = "http,https,{$adminPort}";
|
||||
$ucpPortsList = [];
|
||||
if ($ucpHttpsPort > 0) $ucpPortsList[] = (string)$ucpHttpsPort;
|
||||
if ($ucpHttpPort > 0) $ucpPortsList[] = (string)$ucpHttpPort;
|
||||
$ucpJailEnabled = ($ucpEnabled === 1 && !empty($ucpPortsList)) ? 'true' : 'false';
|
||||
$portsUcp = !empty($ucpPortsList) ? implode(',', $ucpPortsList) : '8444';
|
||||
|
||||
$conf = <<<EOF
|
||||
# Fichier généré automatiquement par NexusPBX
|
||||
@@ -331,6 +406,16 @@ bantime = {$webBanTime}
|
||||
port = {$portsWeb}
|
||||
protocol = tcp
|
||||
|
||||
[nexus-ucp]
|
||||
enabled = {$ucpJailEnabled}
|
||||
filter = nexus-ucp
|
||||
logpath = /var/log/nexus_ucp_security.log
|
||||
maxretry = {$ucpMaxRetry}
|
||||
findtime = 600
|
||||
bantime = {$ucpBanTime}
|
||||
port = {$portsUcp}
|
||||
protocol = tcp
|
||||
|
||||
EOF;
|
||||
|
||||
file_put_contents(self::JAIL_LOCAL_PATH, $conf);
|
||||
@@ -339,10 +424,22 @@ EOF;
|
||||
}
|
||||
|
||||
/**
|
||||
* Met à jour Nginx pour séparer le port 80 (Certbot/ACME uniquement si strict) et écouter sur le port d'admin.
|
||||
* Met à jour Nginx pour séparer le port 80 (Certbot/ACME), le port d'admin et les VHosts isolés de l'UCP.
|
||||
*/
|
||||
public static function applyNginxConfig(int $adminHttpsPort, int $strictPort80): void
|
||||
{
|
||||
public static function applyNginxConfig(
|
||||
int $adminHttpsPort,
|
||||
int $strictPort80,
|
||||
?int $ucpEnabled = null,
|
||||
?int $ucpHttpsPort = null,
|
||||
?int $ucpHttpPort = null
|
||||
): void {
|
||||
if ($ucpEnabled === null || $ucpHttpsPort === null || $ucpHttpPort === null) {
|
||||
$st = self::getSettings();
|
||||
$ucpEnabled = $ucpEnabled ?? $st['ucp_enabled'];
|
||||
$ucpHttpsPort = $ucpHttpsPort ?? $st['ucp_https_port'];
|
||||
$ucpHttpPort = $ucpHttpPort ?? $st['ucp_http_port'];
|
||||
}
|
||||
|
||||
$phpSock = '/run/php/php8.4-fpm.sock';
|
||||
if (!file_exists($phpSock)) {
|
||||
$found = glob('/run/php/php*-fpm.sock');
|
||||
@@ -485,7 +582,19 @@ server {
|
||||
}
|
||||
EOF;
|
||||
|
||||
$fullNginxConfig = $port80Block . "\n\n" . $phonebookBlock . "\n\n" . $httpsBlock . "\n";
|
||||
$blocks = [$port80Block, $phonebookBlock, $httpsBlock];
|
||||
|
||||
// VirtualHosts Nginx totalement isolés pour l'UCP (/var/www/nexus-pbx/ucp)
|
||||
if ($ucpEnabled === 1 && is_dir(self::UCP_ROOT_DIR)) {
|
||||
if ($ucpHttpsPort > 0) {
|
||||
$blocks[] = self::buildUcpServerBlock($ucpHttpsPort, true, $phpSock);
|
||||
}
|
||||
if ($ucpHttpPort > 0) {
|
||||
$blocks[] = self::buildUcpServerBlock($ucpHttpPort, false, $phpSock);
|
||||
}
|
||||
}
|
||||
|
||||
$fullNginxConfig = implode("\n\n", $blocks) . "\n";
|
||||
file_put_contents(self::NGINX_CONF_PATH, $fullNginxConfig);
|
||||
@chgrp(self::NGINX_CONF_PATH, 'asterisk');
|
||||
@chmod(self::NGINX_CONF_PATH, 0664);
|
||||
@@ -500,6 +609,203 @@ EOF;
|
||||
exec('sudo -n /usr/bin/systemctl reload nginx 2>&1');
|
||||
}
|
||||
|
||||
private static function buildUcpServerBlock(int $port, bool $ssl, string $phpSock): string
|
||||
{
|
||||
$ucpRoot = self::UCP_ROOT_DIR;
|
||||
$listenDirectives = $ssl
|
||||
? "listen {$port} ssl;\n listen [::]:{$port} ssl;"
|
||||
: "listen {$port};\n listen [::]:{$port};";
|
||||
|
||||
$sslDirectives = $ssl ? <<<SSL
|
||||
|
||||
ssl_certificate /etc/asterisk/keys/active_cert.crt;
|
||||
ssl_certificate_key /etc/asterisk/keys/active_key.key;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
ssl_prefer_server_ciphers on;
|
||||
SSL : '';
|
||||
|
||||
$logPrefix = $ssl ? 'nexus-ucp.ssl' : 'nexus-ucp';
|
||||
|
||||
return <<<EOF
|
||||
server {
|
||||
{$listenDirectives}
|
||||
server_name _;{$sslDirectives}
|
||||
|
||||
client_max_body_size 16M;
|
||||
|
||||
root {$ucpRoot};
|
||||
index index.php;
|
||||
|
||||
access_log /var/log/nginx/{$logPrefix}.access.log;
|
||||
error_log /var/log/nginx/{$logPrefix}.error.log;
|
||||
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
location / {
|
||||
try_files \$uri \$uri/ /index.php?\$query_string;
|
||||
}
|
||||
|
||||
location ~* \.(?:css|js)$ {
|
||||
expires -1;
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
|
||||
try_files \$uri =404;
|
||||
}
|
||||
|
||||
location ~ \.php$ {
|
||||
include snippets/fastcgi-php.conf;
|
||||
fastcgi_pass unix:{$phpSock};
|
||||
fastcgi_param SCRIPT_FILENAME \$document_root\$fastcgi_script_name;
|
||||
}
|
||||
|
||||
location ~ /\. {
|
||||
deny all;
|
||||
}
|
||||
}
|
||||
EOF;
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée le filtre et le fichier de log Fail2ban dédiés au portail UCP.
|
||||
*/
|
||||
public static function ensureUcpFail2banArtifacts(): void
|
||||
{
|
||||
$filterContent = "[Definition]\nfailregex = ^.*\\[NEXUS-UCP-AUTH-FAIL\\]:\\s*<HOST>.*$\nignoreregex =\n";
|
||||
$isNonRoot = function_exists('posix_geteuid') && posix_geteuid() !== 0;
|
||||
|
||||
if (!file_exists(self::UCP_FILTER_PATH) || @file_get_contents(self::UCP_FILTER_PATH) !== $filterContent) {
|
||||
if (@file_put_contents(self::UCP_FILTER_PATH, $filterContent) !== false) {
|
||||
@chmod(self::UCP_FILTER_PATH, 0644);
|
||||
} elseif ($isNonRoot) {
|
||||
@exec('sudo -n /var/www/nexus-pbx/scripts/nexus-firewall init-ucp >/dev/null 2>&1');
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!file_exists(self::UCP_SECURITY_LOG)) {
|
||||
if (@touch(self::UCP_SECURITY_LOG) === false && $isNonRoot) {
|
||||
@exec('sudo -n /var/www/nexus-pbx/scripts/nexus-firewall init-ucp >/dev/null 2>&1');
|
||||
return;
|
||||
}
|
||||
}
|
||||
@chown(self::UCP_SECURITY_LOG, 'www-data');
|
||||
@chgrp(self::UCP_SECURITY_LOG, 'asterisk');
|
||||
@chmod(self::UCP_SECURITY_LOG, 0664);
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-déploie les VHosts Nginx UCP et le filtre Fail2ban si absents (ex. après git pull).
|
||||
*/
|
||||
public static function ensureUcpDeployed(): void
|
||||
{
|
||||
$st = self::getSettings();
|
||||
if (empty($st['ucp_enabled'])) {
|
||||
return;
|
||||
}
|
||||
$httpsPort = (int)($st['ucp_https_port'] ?? 8444);
|
||||
$httpPort = (int)($st['ucp_http_port'] ?? 8084);
|
||||
if ($httpsPort <= 0 && $httpPort <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$nginxFile = '/etc/nginx/sites-available/nexus-pbx';
|
||||
$nginxConf = is_readable($nginxFile) ? (string)@file_get_contents($nginxFile) : '';
|
||||
$hasNginxUcp = str_contains($nginxConf, self::UCP_ROOT_DIR);
|
||||
$hasFilter = file_exists(self::UCP_FILTER_PATH);
|
||||
$hasLog = file_exists(self::UCP_SECURITY_LOG);
|
||||
|
||||
if ($hasNginxUcp && $hasFilter && $hasLog) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (function_exists('posix_geteuid') && posix_geteuid() !== 0) {
|
||||
exec('sudo -n /var/www/nexus-pbx/scripts/nexus-firewall init-ucp 2>&1', $out, $code);
|
||||
if ($code === 0) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
self::saveSettings($st);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ouvre/ferme les ports UCP configurés dans UFW / iptables uniquement si un filtrage actif existe.
|
||||
*/
|
||||
private static function syncUcpFirewallPorts(array $oldSettings, array $newSettings): void
|
||||
{
|
||||
$oldPorts = [];
|
||||
if (!empty($oldSettings['ucp_enabled'])) {
|
||||
if ((int)($oldSettings['ucp_https_port'] ?? 0) > 0) $oldPorts[] = (int)$oldSettings['ucp_https_port'];
|
||||
if ((int)($oldSettings['ucp_http_port'] ?? 0) > 0) $oldPorts[] = (int)$oldSettings['ucp_http_port'];
|
||||
}
|
||||
$newPorts = [];
|
||||
if (!empty($newSettings['ucp_enabled'])) {
|
||||
if ((int)($newSettings['ucp_https_port'] ?? 0) > 0) $newPorts[] = (int)$newSettings['ucp_https_port'];
|
||||
if ((int)($newSettings['ucp_http_port'] ?? 0) > 0) $newPorts[] = (int)$newSettings['ucp_http_port'];
|
||||
}
|
||||
|
||||
$toRemove = array_diff(array_unique($oldPorts), array_unique($newPorts));
|
||||
$toAdd = array_diff(array_unique($newPorts), array_unique($oldPorts));
|
||||
|
||||
// UFW si installé et actif
|
||||
$ufwStatus = trim((string)@shell_exec('sudo -n /usr/sbin/ufw status 2>/dev/null'));
|
||||
if ($ufwStatus !== '' && stripos($ufwStatus, 'Status: active') !== false) {
|
||||
foreach ($toRemove as $p) {
|
||||
@shell_exec('sudo -n /usr/sbin/ufw delete allow ' . (int)$p . '/tcp >/dev/null 2>&1');
|
||||
}
|
||||
foreach ($newPorts as $p) {
|
||||
@shell_exec('sudo -n /usr/sbin/ufw allow ' . (int)$p . '/tcp >/dev/null 2>&1');
|
||||
}
|
||||
}
|
||||
|
||||
// iptables si la politique INPUT par défaut bloque le trafic (DROP/REJECT)
|
||||
$iptPolicy = trim((string)@shell_exec("sudo -n /usr/sbin/iptables -S INPUT 2>/dev/null | head -n 1"));
|
||||
if (str_contains($iptPolicy, 'DROP') || str_contains($iptPolicy, 'REJECT')) {
|
||||
foreach ($toRemove as $p) {
|
||||
@shell_exec("sudo -n /usr/sbin/iptables -D INPUT -p tcp --dport " . (int)$p . " -j ACCEPT >/dev/null 2>&1");
|
||||
}
|
||||
foreach ($toAdd as $p) {
|
||||
@shell_exec("sudo -n /usr/sbin/iptables -C INPUT -p tcp --dport " . (int)$p . " -j ACCEPT >/dev/null 2>&1 || sudo -n /usr/sbin/iptables -I INPUT -p tcp --dport " . (int)$p . " -j ACCEPT >/dev/null 2>&1");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Résout l'IP réelle du client en tenant compte d'un reverse proxy local / RFC1918 de confiance.
|
||||
*/
|
||||
public static function resolveClientIp(): string
|
||||
{
|
||||
$remote = trim((string)($_SERVER['REMOTE_ADDR'] ?? '127.0.0.1'));
|
||||
if ($remote === '' || !filter_var($remote, FILTER_VALIDATE_IP)) {
|
||||
return '127.0.0.1';
|
||||
}
|
||||
|
||||
// Ne faire confiance aux en-têtes proxy que si la connexion TCP provient d'un réseau privé ou loopback
|
||||
$isTrustedProxy = (
|
||||
filter_var($remote, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false
|
||||
);
|
||||
|
||||
if ($isTrustedProxy) {
|
||||
$candidates = [];
|
||||
if (!empty($_SERVER['HTTP_X_REAL_IP'])) {
|
||||
$candidates[] = trim((string)$_SERVER['HTTP_X_REAL_IP']);
|
||||
}
|
||||
if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
|
||||
foreach (explode(',', (string)$_SERVER['HTTP_X_FORWARDED_FOR']) as $part) {
|
||||
$candidates[] = trim($part);
|
||||
}
|
||||
}
|
||||
foreach ($candidates as $cand) {
|
||||
if ($cand !== '' && filter_var($cand, FILTER_VALIDATE_IP)) {
|
||||
return $cand;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $remote;
|
||||
}
|
||||
|
||||
private static function getJailDetails(string $jail): array
|
||||
{
|
||||
exec('timeout 2 sudo -n /usr/bin/fail2ban-client status ' . escapeshellarg($jail) . ' 2>&1', $out);
|
||||
@@ -526,7 +832,12 @@ EOF;
|
||||
$totalFailed = max($totalFailed, $logIncidents);
|
||||
} elseif ($jail === 'nexus-web') {
|
||||
$logIncidents = is_readable('/var/log/nexus_security.log')
|
||||
? (int)shell_exec("grep -c 'FailedLogin' /var/log/nexus_security.log 2>/dev/null")
|
||||
? (int)shell_exec("grep -c -E 'NEXUS-WEB-AUTH-FAIL|FailedLogin' /var/log/nexus_security.log 2>/dev/null")
|
||||
: 0;
|
||||
$totalFailed = max($totalFailed, $logIncidents);
|
||||
} elseif ($jail === 'nexus-ucp') {
|
||||
$logIncidents = is_readable(self::UCP_SECURITY_LOG)
|
||||
? (int)shell_exec("grep -c 'NEXUS-UCP-AUTH-FAIL' " . escapeshellarg(self::UCP_SECURITY_LOG) . " 2>/dev/null")
|
||||
: 0;
|
||||
$totalFailed = max($totalFailed, $logIncidents);
|
||||
}
|
||||
@@ -558,6 +869,7 @@ EOF;
|
||||
return match ($jail) {
|
||||
'asterisk' => 'Téléphonie SIP (PJSIP)',
|
||||
'nexus-web' => 'Interface Web Admin',
|
||||
'nexus-ucp' => 'Portail Utilisateur (UCP)',
|
||||
'sshd' => 'Accès Console SSH',
|
||||
default => ucfirst($jail),
|
||||
};
|
||||
|
||||
+55
-28
@@ -48,14 +48,22 @@ class PhonebookManager
|
||||
CREATE INDEX IF NOT EXISTS idx_phonebook_group ON phonebook_contacts(group_name);
|
||||
");
|
||||
|
||||
// 1. Récupère la cartographie des groupes d'extensions
|
||||
$egRows = $db->query("SELECT id, name, members FROM extension_groups")->fetchAll();
|
||||
$extToGroup = [];
|
||||
// 1. Récupère la cartographie complète des groupes d'extensions (un poste peut appartenir à plusieurs groupes)
|
||||
$egRows = $db->query("SELECT id, name, members FROM extension_groups ORDER BY name COLLATE NOCASE ASC")->fetchAll();
|
||||
$extToGroups = [];
|
||||
foreach ($egRows as $eg) {
|
||||
$grpName = trim((string)($eg['name'] ?? ''));
|
||||
if ($grpName === '') {
|
||||
continue;
|
||||
}
|
||||
$members = json_decode((string)$eg['members'], true) ?: [];
|
||||
foreach ($members as $extNum) {
|
||||
if (!isset($extToGroup[(string)$extNum])) {
|
||||
$extToGroup[(string)$extNum] = trim((string)$eg['name']);
|
||||
$k = (string)$extNum;
|
||||
if (!isset($extToGroups[$k])) {
|
||||
$extToGroups[$k] = [];
|
||||
}
|
||||
if (!in_array($grpName, $extToGroups[$k], true)) {
|
||||
$extToGroups[$k][] = $grpName;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -80,19 +88,21 @@ class PhonebookManager
|
||||
$extNum = trim((string)$ext['extension']);
|
||||
$extName = trim((string)$ext['name']);
|
||||
$email = trim((string)($ext['voicemail_email'] ?? ''));
|
||||
$group = $extToGroup[$extNum] ?? 'Général';
|
||||
$group = !empty($extToGroups[$extNum]) ? implode(', ', $extToGroups[$extNum]) : 'Général';
|
||||
$activeExtIds[] = $extId;
|
||||
|
||||
$findStmt->execute([':ext_id' => $extId, ':num' => $extNum]);
|
||||
$existing = $findStmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($existing) {
|
||||
$existingEmail = trim((string)($existing['email'] ?? ''));
|
||||
$finalEmail = $existingEmail !== '' ? $existingEmail : $email;
|
||||
$updateStmt->execute([
|
||||
':id' => (int)$existing['id'],
|
||||
':ext_id' => $extId,
|
||||
':name' => $extName,
|
||||
':num' => $extNum,
|
||||
':email' => $email,
|
||||
':email' => $finalEmail,
|
||||
':grp' => $group,
|
||||
]);
|
||||
} else {
|
||||
@@ -122,33 +132,48 @@ class PhonebookManager
|
||||
{
|
||||
self::syncExtensions($db);
|
||||
|
||||
$params = [];
|
||||
$sql = "SELECT * FROM phonebook_contacts";
|
||||
$stmt = $db->query("SELECT * FROM phonebook_contacts ORDER BY display_name COLLATE NOCASE ASC");
|
||||
$contacts = $stmt ? $stmt->fetchAll(PDO::FETCH_ASSOC) : [];
|
||||
|
||||
if ($group !== null && trim($group) !== '' && strtolower(trim($group)) !== 'all' && strtolower(trim($group)) !== 'tous') {
|
||||
$cleanGroup = trim($group);
|
||||
$sql .= " WHERE LOWER(group_name) = LOWER(:grp) OR LOWER(group_name) LIKE LOWER(:grp_like) OR LOWER(group_name) IN ('tous', 'all')";
|
||||
$params[':grp'] = $cleanGroup;
|
||||
$params[':grp_like'] = "%{$cleanGroup}%";
|
||||
}
|
||||
$cleanGroup = ($group !== null) ? trim($group) : '';
|
||||
$filterByGroup = ($cleanGroup !== '' && !in_array(mb_strtolower($cleanGroup), ['all', 'tous'], true));
|
||||
$targetLower = mb_strtolower($cleanGroup);
|
||||
|
||||
$sql .= " ORDER BY display_name COLLATE NOCASE ASC";
|
||||
$stmt = $db->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$contacts = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// Vérification de la présence réelle du fichier sur le disque
|
||||
foreach ($contacts as &$c) {
|
||||
$result = [];
|
||||
foreach ($contacts as $c) {
|
||||
if (!empty($c['photo_url'])) {
|
||||
$filename = basename((string)$c['photo_url']);
|
||||
if (!file_exists(self::AVATARS_DIR . '/' . $filename)) {
|
||||
$c['photo_url'] = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
unset($c);
|
||||
|
||||
return $contacts;
|
||||
$rawGroups = preg_split('/\s*,\s*/', trim((string)($c['group_name'] ?? ''))) ?: [];
|
||||
$groupsList = array_values(array_unique(array_filter($rawGroups, fn($g) => $g !== '')));
|
||||
if (empty($groupsList)) {
|
||||
$groupsList = ['Général'];
|
||||
}
|
||||
$c['groups'] = $groupsList;
|
||||
$c['group_name'] = implode(', ', $groupsList);
|
||||
|
||||
if ($filterByGroup) {
|
||||
$matched = false;
|
||||
foreach ($groupsList as $g) {
|
||||
$gLower = mb_strtolower($g);
|
||||
if ($gLower === $targetLower || in_array($gLower, ['tous', 'all'], true)) {
|
||||
$matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$matched) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$result[] = $c;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -163,9 +188,11 @@ class PhonebookManager
|
||||
if ($g !== '') $groups[$g] = true;
|
||||
}
|
||||
$pbRows = $db->query("SELECT DISTINCT group_name FROM phonebook_contacts WHERE group_name != ''")->fetchAll(PDO::FETCH_COLUMN);
|
||||
foreach ($pbRows as $g) {
|
||||
$g = trim((string)$g);
|
||||
if ($g !== '') $groups[$g] = true;
|
||||
foreach ($pbRows as $raw) {
|
||||
foreach (preg_split('/\s*,\s*/', trim((string)$raw)) ?: [] as $g) {
|
||||
$g = trim((string)$g);
|
||||
if ($g !== '') $groups[$g] = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Ajoute Issy-Les-Moulineaux si pas encore présent
|
||||
|
||||
+197
@@ -0,0 +1,197 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
use PDO;
|
||||
use InvalidArgumentException;
|
||||
|
||||
class UcpAuth
|
||||
{
|
||||
public const SESSION_NAME = 'NEXUS_UCP_SESS';
|
||||
|
||||
public static function startSession(): void
|
||||
{
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_name(self::SESSION_NAME);
|
||||
session_start([
|
||||
'cookie_httponly' => true,
|
||||
'cookie_samesite' => 'Lax',
|
||||
'cookie_path' => '/',
|
||||
]);
|
||||
}
|
||||
if (empty($_SESSION['ucp_csrf'])) {
|
||||
$_SESSION['ucp_csrf'] = bin2hex(random_bytes(24));
|
||||
}
|
||||
}
|
||||
|
||||
public static function getCsrfToken(): string
|
||||
{
|
||||
self::startSession();
|
||||
return (string)$_SESSION['ucp_csrf'];
|
||||
}
|
||||
|
||||
public static function verifyCsrfToken(string $token): bool
|
||||
{
|
||||
self::startSession();
|
||||
$expected = (string)($_SESSION['ucp_csrf'] ?? '');
|
||||
return $expected !== '' && hash_equals($expected, $token);
|
||||
}
|
||||
|
||||
public static function isUcpEnabled(PDO $db): bool
|
||||
{
|
||||
try {
|
||||
$val = $db->query('SELECT ucp_enabled FROM settings WHERE id = 1')->fetchColumn();
|
||||
return $val === false ? true : ((int)$val === 1);
|
||||
} catch (\Throwable) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public static function getCurrentUser(PDO $db): ?array
|
||||
{
|
||||
self::startSession();
|
||||
if (empty($_SESSION['ucp_user_id']) || !self::isUcpEnabled($db)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$stmt = $db->prepare('
|
||||
SELECT id, username, display_name, email, extensions_json, enabled, last_login_at, last_login_ip
|
||||
FROM ucp_users
|
||||
WHERE id = :id
|
||||
LIMIT 1
|
||||
');
|
||||
$stmt->execute([':id' => (int)$_SESSION['ucp_user_id']]);
|
||||
$user = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$user || (int)$user['enabled'] !== 1) {
|
||||
self::logout();
|
||||
return null;
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
public static function isAuthenticated(PDO $db): bool
|
||||
{
|
||||
return self::getCurrentUser($db) !== null;
|
||||
}
|
||||
|
||||
public static function login(PDO $db, string $username, string $password): bool
|
||||
{
|
||||
self::startSession();
|
||||
if (!self::isUcpEnabled($db)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$cleanUser = trim($username);
|
||||
$stmt = $db->prepare('
|
||||
SELECT id, username, display_name, password_hash, enabled
|
||||
FROM ucp_users
|
||||
WHERE username = :u
|
||||
LIMIT 1
|
||||
');
|
||||
$stmt->execute([':u' => $cleanUser]);
|
||||
$user = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($user && (int)$user['enabled'] === 1 && password_verify($password, (string)$user['password_hash'])) {
|
||||
session_regenerate_id(true);
|
||||
$_SESSION['ucp_user_id'] = (int)$user['id'];
|
||||
$_SESSION['ucp_username'] = (string)$user['username'];
|
||||
if (empty($_SESSION['ucp_csrf'])) {
|
||||
$_SESSION['ucp_csrf'] = bin2hex(random_bytes(24));
|
||||
}
|
||||
|
||||
$ip = FirewallManager::resolveClientIp();
|
||||
$db->prepare('UPDATE ucp_users SET last_login_at = CURRENT_TIMESTAMP, last_login_ip = :ip WHERE id = :id')
|
||||
->execute([':ip' => $ip, ':id' => (int)$user['id']]);
|
||||
return true;
|
||||
}
|
||||
|
||||
self::logAuthFailure($cleanUser);
|
||||
return false;
|
||||
}
|
||||
|
||||
public static function logAuthFailure(string $username): void
|
||||
{
|
||||
$clientIp = FirewallManager::resolveClientIp();
|
||||
$safeUser = preg_replace('/[^a-zA-Z0-9._@\-]/', '', $username) ?: 'vide';
|
||||
$logMsg = date('[Y-m-d H:i:s] ') . "[NEXUS-UCP-AUTH-FAIL]: {$clientIp} (username: {$safeUser})\n";
|
||||
@file_put_contents(FirewallManager::UCP_SECURITY_LOG, $logMsg, FILE_APPEND | LOCK_EX);
|
||||
@chmod(FirewallManager::UCP_SECURITY_LOG, 0664);
|
||||
}
|
||||
|
||||
public static function logout(): void
|
||||
{
|
||||
self::startSession();
|
||||
$_SESSION = [];
|
||||
if (ini_get('session.use_cookies')) {
|
||||
$params = session_get_cookie_params();
|
||||
setcookie(
|
||||
session_name(),
|
||||
'',
|
||||
time() - 42000,
|
||||
$params['path'],
|
||||
$params['domain'],
|
||||
$params['secure'],
|
||||
$params['httponly']
|
||||
);
|
||||
}
|
||||
session_destroy();
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne la liste fraîche des extensions autorisées et actives pour un utilisateur UCP.
|
||||
*/
|
||||
public static function getAllowedExtensions(PDO $db, int $ucpUserId): array
|
||||
{
|
||||
$stmt = $db->prepare('SELECT extensions_json, enabled FROM ucp_users WHERE id = :id LIMIT 1');
|
||||
$stmt->execute([':id' => $ucpUserId]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$row || (int)$row['enabled'] !== 1) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$linked = json_decode((string)($row['extensions_json'] ?? '[]'), true);
|
||||
if (!is_array($linked) || empty($linked)) {
|
||||
return [];
|
||||
}
|
||||
$linkedSet = array_values(array_unique(array_map('strval', $linked)));
|
||||
|
||||
$extRows = $db->query('
|
||||
SELECT id, extension, name, transport, encryption, groundwire_enabled, voicemail_enabled, voicemail_email, voicemail_pin, ring_time
|
||||
FROM extensions
|
||||
WHERE enabled = 1
|
||||
ORDER BY extension ASC
|
||||
')->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
||||
|
||||
$allowed = [];
|
||||
foreach ($extRows as $ext) {
|
||||
if (in_array((string)$ext['extension'], $linkedSet, true)) {
|
||||
$allowed[] = $ext;
|
||||
}
|
||||
}
|
||||
return $allowed;
|
||||
}
|
||||
|
||||
public static function updateOwnPassword(PDO $db, int $ucpUserId, string $currentPassword, string $newPassword): void
|
||||
{
|
||||
if (strlen($newPassword) < 6) {
|
||||
throw new InvalidArgumentException("Le nouveau mot de passe doit contenir au moins 6 caractères.");
|
||||
}
|
||||
|
||||
$stmt = $db->prepare('SELECT password_hash FROM ucp_users WHERE id = :id LIMIT 1');
|
||||
$stmt->execute([':id' => $ucpUserId]);
|
||||
$hash = $stmt->fetchColumn();
|
||||
if (!$hash || !password_verify($currentPassword, (string)$hash)) {
|
||||
throw new InvalidArgumentException("Le mot de passe actuel est incorrect.");
|
||||
}
|
||||
|
||||
$db->prepare('UPDATE ucp_users SET password_hash = :p, updated_at = CURRENT_TIMESTAMP WHERE id = :id')
|
||||
->execute([
|
||||
':p' => password_hash($newPassword, PASSWORD_DEFAULT),
|
||||
':id' => $ucpUserId,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
+1024
File diff suppressed because it is too large.
Load diff
+348
@@ -0,0 +1,348 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../src/Database.php';
|
||||
require_once __DIR__ . '/../src/FirewallManager.php';
|
||||
require_once __DIR__ . '/../src/AmiClient.php';
|
||||
require_once __DIR__ . '/../src/ConfigGenerator.php';
|
||||
require_once __DIR__ . '/../src/CdrManager.php';
|
||||
require_once __DIR__ . '/../src/PhonebookManager.php';
|
||||
require_once __DIR__ . '/../src/UcpAuth.php';
|
||||
require_once __DIR__ . '/../src/UcpManager.php';
|
||||
|
||||
use App\Database;
|
||||
use App\UcpAuth;
|
||||
use App\UcpManager;
|
||||
|
||||
$action = trim((string)($_GET['action'] ?? $_POST['action'] ?? ''));
|
||||
$isBinaryEndpoint = in_array($action, ['stream_voicemail', 'avatar'], true);
|
||||
|
||||
if (!$isBinaryEndpoint) {
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
}
|
||||
|
||||
try {
|
||||
$db = Database::getConnection();
|
||||
UcpAuth::startSession();
|
||||
|
||||
if (!UcpAuth::isUcpEnabled($db)) {
|
||||
http_response_code(503);
|
||||
echo json_encode(['success' => false, 'message' => 'Le portail utilisateur UCP est actuellement désactivé par l’administrateur.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Lecture du payload JSON si applicable
|
||||
$payload = $_POST;
|
||||
$contentType = (string)($_SERVER['CONTENT_TYPE'] ?? '');
|
||||
if (str_contains($contentType, 'application/json')) {
|
||||
$raw = file_get_contents('php://input');
|
||||
$decoded = json_decode((string)$raw, true);
|
||||
if (is_array($decoded)) {
|
||||
$payload = $decoded;
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 1. Authentification (routes publiques de session)
|
||||
// -------------------------------------------------------------------------
|
||||
if ($action === 'login') {
|
||||
$username = (string)($payload['username'] ?? '');
|
||||
$password = (string)($payload['password'] ?? '');
|
||||
|
||||
if (UcpAuth::login($db, $username, $password)) {
|
||||
$user = UcpAuth::getCurrentUser($db);
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'username' => $user['username'] ?? $username,
|
||||
'csrf_token' => UcpAuth::getCsrfToken(),
|
||||
'message' => 'Connexion réussie.',
|
||||
]);
|
||||
} else {
|
||||
http_response_code(401);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'message' => 'Identifiant ou mot de passe incorrect.',
|
||||
]);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($action === 'logout') {
|
||||
UcpAuth::logout();
|
||||
echo json_encode(['success' => true, 'message' => 'Déconnexion effectuée.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 2. Barrière d'authentification UCP obligatoire
|
||||
// -------------------------------------------------------------------------
|
||||
$currentUser = UcpAuth::getCurrentUser($db);
|
||||
if (!$currentUser) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['success' => false, 'message' => 'Session expirée ou non authentifiée.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Protection CSRF sur toutes les actions modificatrices POST
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$csrfHeader = (string)($_SERVER['HTTP_X_CSRF_TOKEN'] ?? $payload['csrf_token'] ?? '');
|
||||
if (!UcpAuth::verifyCsrfToken($csrfHeader)) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'message' => 'Jeton de sécurité CSRF invalide. Rechargez la page.']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
$ucpUserId = (int)$currentUser['id'];
|
||||
$allowedExts = UcpAuth::getAllowedExtensions($db, $ucpUserId);
|
||||
|
||||
switch ($action) {
|
||||
case 'get_state':
|
||||
$branding = $db->query('SELECT company_name, company_logo, ucp_logo FROM settings WHERE id = 1 LIMIT 1')->fetch(PDO::FETCH_ASSOC) ?: [];
|
||||
$compLogo = trim((string)($branding['company_logo'] ?? ''));
|
||||
$ucpLogo = trim((string)($branding['ucp_logo'] ?? ''));
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'user' => [
|
||||
'id' => $ucpUserId,
|
||||
'username' => (string)$currentUser['username'],
|
||||
'display_name' => (string)$currentUser['display_name'],
|
||||
'email' => (string)$currentUser['email'],
|
||||
'last_login_at' => $currentUser['last_login_at'],
|
||||
],
|
||||
'branding' => [
|
||||
'company_name' => (string)($branding['company_name'] ?? ''),
|
||||
'company_logo' => $compLogo,
|
||||
'ucp_logo' => $ucpLogo,
|
||||
'effective_logo' => $ucpLogo !== '' ? $ucpLogo : $compLogo,
|
||||
],
|
||||
'csrf_token' => UcpAuth::getCsrfToken(),
|
||||
'extensions' => UcpManager::getExtensionsState($db, $allowedExts, $ucpUserId),
|
||||
'forward_targets' => UcpManager::getForwardTargets($db),
|
||||
'feature_codes' => UcpManager::getFeatureCodesForUser($db),
|
||||
],
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'save_call_features':
|
||||
$ext = (string)($payload['extension'] ?? '');
|
||||
UcpManager::setCallFeatures($db, $allowedExts, $ext, [
|
||||
'dnd' => !empty($payload['dnd']),
|
||||
'cf' => (string)($payload['cf'] ?? ''),
|
||||
'cfu' => (string)($payload['cfu'] ?? ''),
|
||||
]);
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'extensions' => UcpManager::getExtensionsState($db, $allowedExts, $ucpUserId),
|
||||
'message' => "Paramètres d'appel du poste {$ext} appliqués en direct.",
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'get_voicemails':
|
||||
$ext = (string)($_GET['extension'] ?? $payload['extension'] ?? '');
|
||||
if ($ext === '' && !empty($allowedExts)) {
|
||||
$ext = (string)$allowedExts[0]['extension'];
|
||||
}
|
||||
$messages = $ext !== '' ? UcpManager::getVoicemails($allowedExts, $ext) : [];
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'messages' => $messages,
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'stream_voicemail':
|
||||
$ext = (string)($_GET['extension'] ?? '');
|
||||
$folder = (string)($_GET['folder'] ?? '');
|
||||
$msgId = (string)($_GET['id'] ?? '');
|
||||
$download = !empty($_GET['download']);
|
||||
|
||||
$filePath = UcpManager::getVoicemailAudioPath($allowedExts, $ext, $folder, $msgId);
|
||||
$size = filesize($filePath);
|
||||
$fileExt = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));
|
||||
$mime = ($fileExt === 'gsm') ? 'audio/gsm' : 'audio/wav';
|
||||
|
||||
while (ob_get_level()) {
|
||||
ob_end_clean();
|
||||
}
|
||||
|
||||
header("Content-Type: {$mime}");
|
||||
header('Accept-Ranges: bytes');
|
||||
header('Cache-Control: no-store, no-cache, must-revalidate');
|
||||
if ($download) {
|
||||
$dlName = "voicemail_{$ext}_{$msgId}.{$fileExt}";
|
||||
header("Content-Disposition: attachment; filename=\"{$dlName}\"");
|
||||
}
|
||||
|
||||
if (isset($_SERVER['HTTP_RANGE'])) {
|
||||
[$param, $range] = explode('=', $_SERVER['HTTP_RANGE'], 2);
|
||||
if (strtolower(trim($param)) === 'bytes') {
|
||||
[$from, $to] = explode('-', $range, 2);
|
||||
$from = intval($from);
|
||||
$to = ($to === '') ? ($size - 1) : intval($to);
|
||||
if ($from > $to || $from >= $size) {
|
||||
http_response_code(416);
|
||||
exit;
|
||||
}
|
||||
$length = $to - $from + 1;
|
||||
http_response_code(206);
|
||||
header("Content-Range: bytes {$from}-{$to}/{$size}");
|
||||
header("Content-Length: {$length}");
|
||||
$fp = fopen($filePath, 'rb');
|
||||
fseek($fp, $from);
|
||||
$remaining = $length;
|
||||
while ($remaining > 0 && !feof($fp)) {
|
||||
$chunk = min(8192, $remaining);
|
||||
echo fread($fp, $chunk);
|
||||
$remaining -= $chunk;
|
||||
flush();
|
||||
}
|
||||
fclose($fp);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
header("Content-Length: {$size}");
|
||||
readfile($filePath);
|
||||
exit;
|
||||
|
||||
case 'toggle_voicemail_read':
|
||||
$ext = (string)($payload['extension'] ?? '');
|
||||
$folder = (string)($payload['folder'] ?? '');
|
||||
$msgId = (string)($payload['id'] ?? '');
|
||||
UcpManager::moveVoicemailFolder($db, $allowedExts, $ext, $folder, $msgId);
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'messages' => UcpManager::getVoicemails($allowedExts, $ext),
|
||||
'extensions' => UcpManager::getExtensionsState($db, $allowedExts),
|
||||
'message' => ($folder === 'INBOX') ? 'Message marqué comme lu (archivé).' : 'Message marqué comme non lu.',
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'delete_voicemail':
|
||||
$ext = (string)($payload['extension'] ?? '');
|
||||
$folder = (string)($payload['folder'] ?? '');
|
||||
$msgId = (string)($payload['id'] ?? '');
|
||||
UcpManager::deleteVoicemail($db, $allowedExts, $ext, $folder, $msgId);
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'messages' => UcpManager::getVoicemails($allowedExts, $ext),
|
||||
'extensions' => UcpManager::getExtensionsState($db, $allowedExts),
|
||||
'message' => 'Message vocal supprimé définitivement.',
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'save_voicemail_prefs':
|
||||
$ext = (string)($payload['extension'] ?? '');
|
||||
$pin = (string)($payload['pin'] ?? '');
|
||||
$email = (string)($payload['email'] ?? '');
|
||||
UcpManager::updateVoicemailPrefs($db, $allowedExts, $ext, $pin, $email);
|
||||
$freshAllowed = UcpAuth::getAllowedExtensions($db, $ucpUserId);
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'extensions' => UcpManager::getExtensionsState($db, $freshAllowed),
|
||||
'message' => 'Préférences messagerie vocale enregistrées et appliquées.',
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'get_personal_cdr':
|
||||
$data = UcpManager::getPersonalCdr($db, $allowedExts, [
|
||||
'direction' => $_GET['direction'] ?? 'all',
|
||||
'disposition' => $_GET['disposition'] ?? 'all',
|
||||
'period' => $_GET['period'] ?? 'all',
|
||||
'search' => $_GET['search'] ?? '',
|
||||
]);
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'data' => $data,
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'get_phonebook':
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'contacts' => UcpManager::getPhonebookForUser($db, $allowedExts),
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'save_my_email':
|
||||
$ext = (string)($payload['extension'] ?? '');
|
||||
$email = (string)($payload['email'] ?? '');
|
||||
UcpManager::updateMyContactEmail($db, $allowedExts, $ext, $email);
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'contacts' => UcpManager::getPhonebookForUser($db, $allowedExts),
|
||||
'extensions' => UcpManager::getExtensionsState($db, $allowedExts),
|
||||
'message' => "E-mail annuaire du poste {$ext} mis à jour.",
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'upload_my_avatar':
|
||||
$ext = (string)($_POST['extension'] ?? '');
|
||||
if (empty($_FILES['avatar'])) {
|
||||
throw new InvalidArgumentException("Aucun fichier image reçu.");
|
||||
}
|
||||
$avatarUrl = UcpManager::uploadMyAvatar($db, $allowedExts, $ext, $_FILES['avatar']);
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'photo_url' => $avatarUrl,
|
||||
'contacts' => UcpManager::getPhonebookForUser($db, $allowedExts),
|
||||
'extensions' => UcpManager::getExtensionsState($db, $allowedExts),
|
||||
'message' => "Photo de profil du poste {$ext} mise à jour.",
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'avatar':
|
||||
$file = (string)($_GET['file'] ?? '');
|
||||
$path = UcpManager::resolveAvatarPath($file);
|
||||
if (!$path) {
|
||||
http_response_code(404);
|
||||
exit;
|
||||
}
|
||||
$extName = strtolower(pathinfo($path, PATHINFO_EXTENSION));
|
||||
$mime = match ($extName) {
|
||||
'png' => 'image/png',
|
||||
'webp' => 'image/webp',
|
||||
default => 'image/jpeg',
|
||||
};
|
||||
while (ob_get_level()) {
|
||||
ob_end_clean();
|
||||
}
|
||||
header("Content-Type: {$mime}");
|
||||
header('Cache-Control: public, max-age=3600');
|
||||
readfile($path);
|
||||
exit;
|
||||
|
||||
case 'change_password':
|
||||
$cur = (string)($payload['current_password'] ?? '');
|
||||
$new = (string)($payload['new_password'] ?? '');
|
||||
UcpAuth::updateOwnPassword($db, $ucpUserId, $cur, $new);
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'message' => 'Mot de passe UCP modifié avec succès.',
|
||||
]);
|
||||
break;
|
||||
|
||||
default:
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'message' => 'Action UCP inconnue.']);
|
||||
break;
|
||||
}
|
||||
} catch (InvalidArgumentException $e) {
|
||||
http_response_code(400);
|
||||
if ($isBinaryEndpoint) {
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
echo $e->getMessage();
|
||||
} else {
|
||||
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
http_response_code(500);
|
||||
if ($isBinaryEndpoint) {
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
echo 'Erreur serveur.';
|
||||
} else {
|
||||
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 8.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 795 KiB |
+1207
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../src/Database.php';
|
||||
require_once __DIR__ . '/../src/FirewallManager.php';
|
||||
require_once __DIR__ . '/../src/AmiClient.php';
|
||||
require_once __DIR__ . '/../src/ConfigGenerator.php';
|
||||
require_once __DIR__ . '/../src/CdrManager.php';
|
||||
require_once __DIR__ . '/../src/PhonebookManager.php';
|
||||
require_once __DIR__ . '/../src/UcpAuth.php';
|
||||
require_once __DIR__ . '/../src/UcpManager.php';
|
||||
|
||||
use App\Database;
|
||||
use App\UcpAuth;
|
||||
use App\UcpManager;
|
||||
|
||||
try {
|
||||
$db = Database::getConnection();
|
||||
|
||||
if (!UcpAuth::isUcpEnabled($db)) {
|
||||
http_response_code(503);
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
echo "Le portail utilisateur UCP est actuellement désactivé.";
|
||||
exit;
|
||||
}
|
||||
|
||||
$extId = (int)($_GET['ext_id'] ?? $_GET['line_id'] ?? 0);
|
||||
$uid = (int)($_GET['uid'] ?? 0);
|
||||
$token = trim((string)($_GET['token'] ?? ''));
|
||||
|
||||
$authorized = false;
|
||||
|
||||
// 1. Authentification sans session par signature HMAC GET (utilisée par l'application GroundWire / Acrobits)
|
||||
if ($token !== '' && $extId > 0 && $uid > 0) {
|
||||
if (UcpManager::verifyGroundwireToken($db, $extId, $uid, $token)) {
|
||||
$allowed = UcpAuth::getAllowedExtensions($db, $uid);
|
||||
foreach ($allowed as $row) {
|
||||
if ((int)$row['id'] === $extId && (int)($row['groundwire_enabled'] ?? 0) === 1) {
|
||||
$authorized = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Sinon : utilisateur UCP connecté en session et propriétaire de l'extension
|
||||
if (!$authorized && $extId > 0) {
|
||||
UcpAuth::startSession();
|
||||
$sessionUser = UcpAuth::getCurrentUser($db);
|
||||
if ($sessionUser !== null) {
|
||||
$allowed = UcpAuth::getAllowedExtensions($db, (int)$sessionUser['id']);
|
||||
foreach ($allowed as $row) {
|
||||
if ((int)$row['id'] === $extId && (int)($row['groundwire_enabled'] ?? 0) === 1) {
|
||||
$authorized = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$authorized) {
|
||||
http_response_code(403);
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
echo "Accès refusé ou lien de provisionnement invalide.";
|
||||
exit;
|
||||
}
|
||||
|
||||
$bundle = UcpManager::buildGroundwireXml($db, $extId);
|
||||
$xmlContent = $bundle['xml'];
|
||||
|
||||
while (ob_get_level()) {
|
||||
ob_end_clean();
|
||||
}
|
||||
|
||||
header('Content-Type: application/xml; charset=utf-8');
|
||||
header('Content-Disposition: attachment; filename="' . $bundle['filename'] . '"');
|
||||
header('Content-Length: ' . strlen($xmlContent));
|
||||
header('Cache-Control: no-store, no-cache, must-revalidate');
|
||||
|
||||
echo $xmlContent;
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
http_response_code(404);
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
echo $e->getMessage();
|
||||
} catch (\Throwable $e) {
|
||||
http_response_code(500);
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
echo "Erreur interne lors de la génération du profil XML.";
|
||||
}
|
||||
+578
@@ -0,0 +1,578 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../src/Database.php';
|
||||
require_once __DIR__ . '/../src/FirewallManager.php';
|
||||
require_once __DIR__ . '/../src/UcpAuth.php';
|
||||
|
||||
use App\Database;
|
||||
use App\UcpAuth;
|
||||
|
||||
$db = Database::getConnection();
|
||||
$ucpEnabled = UcpAuth::isUcpEnabled($db);
|
||||
$currentUser = $ucpEnabled ? UcpAuth::getCurrentUser($db) : null;
|
||||
$isAuth = ($currentUser !== null);
|
||||
|
||||
$branding = $db->query('SELECT company_name, company_logo, ucp_logo FROM settings WHERE id = 1 LIMIT 1')->fetch(\PDO::FETCH_ASSOC) ?: [];
|
||||
$companyName = trim((string)($branding['company_name'] ?? ''));
|
||||
$companyLogo = trim((string)($branding['company_logo'] ?? ''));
|
||||
$ucpLogo = trim((string)($branding['ucp_logo'] ?? ''));
|
||||
$ucpEffectiveLogo = $ucpLogo !== '' ? $ucpLogo : $companyLogo;
|
||||
$appVerData = file_exists(__DIR__ . '/../version.json') ? json_decode((string)file_get_contents(__DIR__ . '/../version.json'), true) : [];
|
||||
$appVersion = $appVerData['version'] ?? '2.5.0';
|
||||
$csrfToken = UcpAuth::getCsrfToken();
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr" class="h-full bg-slate-50">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<title><?= htmlspecialchars($companyName ?: 'NexusPBX') ?> — Portail Utilisateur (UCP)</title>
|
||||
<link rel="icon" type="image/png" href="/assets/favicon.png?v=<?= file_exists(__DIR__ . '/assets/favicon.png') ? filemtime(__DIR__ . '/assets/favicon.png') : 1 ?>">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'],
|
||||
mono: ['JetBrains Mono', 'Fira Code', 'monospace'],
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
<style>
|
||||
html, body {
|
||||
max-width: 100%;
|
||||
overflow-x: hidden;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
body { background-color: #f8fafc; color: #0f172a; }
|
||||
.ucp-card { background: #ffffff; border: 1px solid #e2e8f0; box-shadow: 0 1px 3px rgba(15, 23, 42, 0.04); }
|
||||
@media screen and (max-width: 639px) {
|
||||
input, select, textarea {
|
||||
font-size: 16px !important;
|
||||
}
|
||||
}
|
||||
.ucp-nav-scroll {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
.ucp-nav-scroll::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
::-webkit-scrollbar { width: 7px; height: 7px; }
|
||||
::-webkit-scrollbar-track { background: #f1f5f9; }
|
||||
::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 4px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
|
||||
|
||||
@page {
|
||||
size: A4 portrait;
|
||||
margin: 11mm 12mm;
|
||||
}
|
||||
@media print {
|
||||
html, body {
|
||||
background: #ffffff !important;
|
||||
color: #0f172a !important;
|
||||
-webkit-print-color-adjust: exact !important;
|
||||
print-color-adjust: exact !important;
|
||||
}
|
||||
header, #ucp-toast-container, #ucp-profile-modal, .no-print {
|
||||
display: none !important;
|
||||
}
|
||||
main {
|
||||
max-width: 100% !important;
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
.ucp-panel {
|
||||
display: none !important;
|
||||
}
|
||||
#ucp-panel-guide {
|
||||
display: block !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
#ucp-guide-printable {
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
.ucp-print-avoid-break {
|
||||
break-inside: avoid !important;
|
||||
page-break-inside: avoid !important;
|
||||
}
|
||||
.ucp-print-3col {
|
||||
display: grid !important;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr)) !important;
|
||||
gap: 0.75rem !important;
|
||||
}
|
||||
.ucp-print-row {
|
||||
flex-direction: row !important;
|
||||
align-items: center !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-full font-sans antialiased text-slate-800">
|
||||
|
||||
<!-- Toast Container -->
|
||||
<div id="ucp-toast-container" class="fixed top-4 inset-x-3 sm:inset-x-auto sm:right-4 z-50 flex flex-col gap-2 sm:max-w-sm sm:w-full pointer-events-none"></div>
|
||||
|
||||
<?php if (!$ucpEnabled): ?>
|
||||
<div class="min-h-screen flex items-center justify-center p-6">
|
||||
<div class="max-w-md w-full ucp-card rounded-2xl p-8 text-center space-y-3">
|
||||
<div class="w-12 h-12 mx-auto rounded-xl bg-amber-50 border border-amber-200 flex items-center justify-center text-amber-600">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z"/></svg>
|
||||
</div>
|
||||
<h1 class="text-lg font-bold text-slate-900">Portail Utilisateur désactivé</h1>
|
||||
<p class="text-sm text-slate-500">Ce service a été temporairement désactivé par votre administrateur système.</p>
|
||||
</div>
|
||||
</div>
|
||||
<?php elseif (!$isAuth): ?>
|
||||
<!-- ÉCRAN DE CONNEXION UCP (WALLPAPER CLAIR + CARTE GLASSMORPHISM) -->
|
||||
<div class="min-h-screen flex flex-col items-center justify-center p-4 relative overflow-hidden bg-slate-100">
|
||||
<div class="absolute inset-0 bg-cover bg-center bg-no-repeat" style="background-image: url('/assets/login-bg.jpg?v=<?= file_exists(__DIR__ . '/assets/login-bg.jpg') ? filemtime(__DIR__ . '/assets/login-bg.jpg') : 1 ?>');"></div>
|
||||
<div class="absolute inset-0 bg-white/10"></div>
|
||||
<div class="relative z-10 w-full max-w-md bg-white/85 backdrop-blur-xl rounded-3xl p-8 shadow-xl shadow-indigo-900/10 border border-white/80">
|
||||
<div class="flex flex-col items-center text-center mb-7">
|
||||
<?php if ($ucpEffectiveLogo !== ''): ?>
|
||||
<img src="<?= htmlspecialchars($ucpEffectiveLogo) ?>" alt="Logo" class="h-14 w-auto object-contain mb-3 rounded-xl">
|
||||
<?php else: ?>
|
||||
<img src="/assets/logo.png?v=1" alt="NexusPBX" class="h-14 w-auto object-contain mb-3">
|
||||
<?php endif; ?>
|
||||
<span class="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-indigo-50 border border-indigo-100 text-indigo-700 text-[11px] font-semibold uppercase tracking-wider mb-2">
|
||||
Portail Utilisateur • UCP
|
||||
</span>
|
||||
<h1 class="text-xl font-bold text-slate-900"><?= htmlspecialchars($companyName ?: 'NexusPBX') ?></h1>
|
||||
<p class="text-xs text-slate-500 mt-1">Connectez-vous pour gérer vos postes, renvois, messagerie et annuaire</p>
|
||||
</div>
|
||||
|
||||
<form id="ucp-login-form" class="space-y-4">
|
||||
<div>
|
||||
<label for="ucp-login-user" class="block text-xs font-semibold text-slate-700 mb-1.5">Identifiant utilisateur</label>
|
||||
<input type="text" id="ucp-login-user" name="username" required autocomplete="username" autofocus
|
||||
placeholder="Votre identifiant"
|
||||
class="w-full px-3.5 py-2.5 rounded-xl bg-slate-50 border border-slate-300 text-base sm:text-sm text-slate-900 focus:bg-white focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500 focus:outline-none transition">
|
||||
</div>
|
||||
<div>
|
||||
<label for="ucp-login-pass" class="block text-xs font-semibold text-slate-700 mb-1.5">Mot de passe</label>
|
||||
<input type="password" id="ucp-login-pass" name="password" required autocomplete="current-password"
|
||||
placeholder="••••••••"
|
||||
class="w-full px-3.5 py-2.5 rounded-xl bg-slate-50 border border-slate-300 text-base sm:text-sm text-slate-900 focus:bg-white focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500 focus:outline-none transition">
|
||||
</div>
|
||||
<div id="ucp-login-error" class="hidden rounded-xl bg-rose-50 border border-rose-200 px-3.5 py-2.5 text-xs font-medium text-rose-700"></div>
|
||||
<button type="submit" id="ucp-login-btn"
|
||||
class="w-full py-2.5 px-4 rounded-xl bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800 text-white text-sm font-semibold shadow-sm shadow-indigo-500/20 transition">
|
||||
Se connecter au portail
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-6 pt-5 border-t border-slate-100 text-center text-[11px] text-slate-400">
|
||||
Espace utilisateur isolé • Protégé par Fail2ban • NexusPBX v<?= htmlspecialchars($appVersion) ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<!-- INTERFACE PRINCIPALE UCP -->
|
||||
<div class="min-h-screen flex flex-col overflow-x-hidden">
|
||||
<!-- Top Navbar -->
|
||||
<header class="sticky top-0 z-30 bg-white/95 backdrop-blur border-b border-slate-200">
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 h-16 flex items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<?php if ($ucpEffectiveLogo !== ''): ?>
|
||||
<img src="<?= htmlspecialchars($ucpEffectiveLogo) ?>" alt="Logo" class="h-9 w-auto rounded-lg object-contain shrink-0">
|
||||
<?php else: ?>
|
||||
<img src="/assets/logo.png?v=1" alt="NexusPBX" class="h-9 w-auto object-contain shrink-0">
|
||||
<?php endif; ?>
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-bold text-slate-900 text-sm sm:text-base truncate"><?= htmlspecialchars($companyName ?: 'NexusPBX') ?></span>
|
||||
<span class="hidden sm:inline-flex px-2 py-0.5 rounded-full bg-indigo-50 text-indigo-700 border border-indigo-100 text-[10px] font-bold uppercase tracking-wide">UCP</span>
|
||||
</div>
|
||||
<p class="text-[11px] text-slate-500 truncate"><span class="hidden sm:inline">Espace personnel de </span><strong class="text-slate-700"><?= htmlspecialchars((string)($currentUser['display_name'] ?: $currentUser['username'])) ?></strong></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<button type="button" onclick="refreshUcpState(true)" title="Actualiser" aria-label="Actualiser"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl border border-slate-200 bg-white hover:bg-slate-50 text-xs font-semibold text-slate-600 transition">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
|
||||
<span class="hidden sm:inline">Actualiser</span>
|
||||
</button>
|
||||
<button type="button" onclick="ucpLogout()" title="Déconnexion" aria-label="Déconnexion"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl border border-rose-200 bg-rose-50 hover:bg-rose-100 text-xs font-semibold text-rose-700 transition">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/></svg>
|
||||
<span class="hidden sm:inline">Déconnexion</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Navigation Onglets avec dégradé latéral discret sur mobile -->
|
||||
<div class="relative max-w-6xl mx-auto border-t border-slate-100">
|
||||
<button type="button" id="ucp-nav-hint-left" onclick="scrollUcpNav(-1)" aria-label="Onglets précédents"
|
||||
class="hidden absolute left-0 top-0 bottom-0 z-10 w-9 items-center justify-start pl-1 bg-gradient-to-r from-white via-white/95 to-transparent text-slate-400 hover:text-indigo-600 transition">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.2" d="M15 19l-7-7 7-7"/></svg>
|
||||
</button>
|
||||
<div id="ucp-nav-scroller" class="ucp-nav-scroll px-4 sm:px-6 overflow-x-auto scroll-smooth">
|
||||
<nav class="flex items-center gap-1 py-2 whitespace-nowrap pr-6 sm:pr-0" id="ucp-nav">
|
||||
<button type="button" data-ucp-tab="extensions" onclick="switchUcpTab('extensions')"
|
||||
class="ucp-tab-btn px-3.5 py-2 rounded-xl text-xs font-semibold transition flex items-center gap-2 bg-indigo-50 text-indigo-700">
|
||||
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3"/></svg>
|
||||
<span>Mes Postes & Renvois</span>
|
||||
<span id="ucp-badge-exts" class="px-1.5 py-0.5 rounded-full bg-indigo-100 text-indigo-700 text-[10px] font-bold">0</span>
|
||||
</button>
|
||||
<button type="button" data-ucp-tab="voicemail" onclick="switchUcpTab('voicemail')"
|
||||
class="ucp-tab-btn px-3.5 py-2 rounded-xl text-xs font-semibold transition flex items-center gap-2 text-slate-600 hover:bg-slate-100">
|
||||
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M21.75 9v.906a2.25 2.25 0 01-1.183 1.981l-6.478 3.488M2.25 9v.906a2.25 2.25 0 001.183 1.981l6.478 3.488m8.839 2.51l-4.66-2.51m0 0l-1.023-.55a2.25 2.25 0 00-2.134 0l-1.022.55m0 0l-4.661 2.51m16.5 1.615a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V8.844a2.25 2.25 0 011.183-1.98l7.5-4.04a2.25 2.25 0 012.134 0l7.5 4.04a2.25 2.25 0 011.183 1.98V19.5z"/></svg>
|
||||
<span>Messagerie Vocale</span>
|
||||
<span id="ucp-badge-vm" class="px-1.5 py-0.5 rounded-full bg-slate-200 text-slate-700 text-[10px] font-bold">0</span>
|
||||
</button>
|
||||
<button type="button" data-ucp-tab="cdr" onclick="switchUcpTab('cdr')"
|
||||
class="ucp-tab-btn px-3.5 py-2 rounded-xl text-xs font-semibold transition flex items-center gap-2 text-slate-600 hover:bg-slate-100">
|
||||
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 6.75c0 8.284 6.716 15 15 15h2.25a2.25 2.25 0 002.25-2.25v-1.372c0-.516-.351-.966-.852-1.091l-4.423-1.106c-.44-.11-.902.055-1.173.417l-.97 1.293c-.282.376-.769.542-1.21.38a12.035 12.035 0 01-7.143-7.143c-.162-.441.004-.928.38-1.21l1.293-.97c.363-.271.527-.734.417-1.173L6.963 3.102a1.125 1.125 0 00-1.091-.852H4.5A2.25 2.25 0 002.25 4.5v2.25z"/></svg>
|
||||
<span>Mes Appels</span>
|
||||
</button>
|
||||
<button type="button" data-ucp-tab="phonebook" onclick="switchUcpTab('phonebook')"
|
||||
class="ucp-tab-btn px-3.5 py-2 rounded-xl text-xs font-semibold transition flex items-center gap-2 text-slate-600 hover:bg-slate-100">
|
||||
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25"/></svg>
|
||||
<span>Annuaire & Profil</span>
|
||||
</button>
|
||||
<button type="button" data-ucp-tab="guide" onclick="switchUcpTab('guide')"
|
||||
class="ucp-tab-btn px-3.5 py-2 rounded-xl text-xs font-semibold transition flex items-center gap-2 text-slate-600 hover:bg-slate-100">
|
||||
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.8 0A2.251 2.251 0 0113.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25z"/></svg>
|
||||
<span>Notice & Codes</span>
|
||||
</button>
|
||||
<button type="button" data-ucp-tab="account" onclick="switchUcpTab('account')"
|
||||
class="ucp-tab-btn px-3.5 py-2 rounded-xl text-xs font-semibold transition flex items-center gap-2 text-slate-600 hover:bg-slate-100">
|
||||
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z"/></svg>
|
||||
<span>Mon Compte</span>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
<button type="button" id="ucp-nav-hint-right" onclick="scrollUcpNav(1)" aria-label="Onglets suivants"
|
||||
class="hidden absolute right-0 top-0 bottom-0 z-10 w-10 items-center justify-end pr-1.5 bg-gradient-to-l from-white via-white/95 to-transparent text-slate-400 hover:text-indigo-600 transition">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.2" d="M9 5l7 7-7 7"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Contenu principal -->
|
||||
<main class="flex-1 max-w-6xl w-full mx-auto px-4 sm:px-6 py-6 space-y-6">
|
||||
|
||||
<!-- ONGLET 1 : MES POSTES & RENVOIS -->
|
||||
<section id="ucp-panel-extensions" class="ucp-panel space-y-5">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-lg font-bold text-slate-900">Mes Postes & Redirections</h2>
|
||||
<p class="text-xs text-slate-500">Activez le mode Ne Pas Déranger (DND) ou configurez vos renvois vers un autre poste ou votre messagerie vocale.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="ucp-extensions-grid" class="grid grid-cols-1 lg:grid-cols-2 gap-5">
|
||||
<div class="ucp-card rounded-2xl p-8 text-center text-sm text-slate-400 col-span-full">Chargement de vos postes…</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ONGLET 2 : MESSAGERIE VOCALE -->
|
||||
<section id="ucp-panel-voicemail" class="ucp-panel hidden space-y-5">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-lg font-bold text-slate-900">Messagerie Vocale</h2>
|
||||
<p class="text-xs text-slate-500">Écoutez, téléchargez, archivez ou supprimez vos messages vocaux et gérez votre code PIN.</p>
|
||||
</div>
|
||||
<div id="ucp-vm-ext-pills" class="flex flex-wrap gap-2"></div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<!-- Liste des messages -->
|
||||
<div class="lg:col-span-2 space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="inline-flex rounded-xl bg-white border border-slate-200 p-1 text-xs font-semibold">
|
||||
<button type="button" onclick="setVmFolderFilter('all')" id="vm-filter-all" class="px-3 py-1.5 rounded-lg bg-indigo-50 text-indigo-700">Tous</button>
|
||||
<button type="button" onclick="setVmFolderFilter('INBOX')" id="vm-filter-INBOX" class="px-3 py-1.5 rounded-lg text-slate-600 hover:bg-slate-50">Nouveaux</button>
|
||||
<button type="button" onclick="setVmFolderFilter('Old')" id="vm-filter-Old" class="px-3 py-1.5 rounded-lg text-slate-600 hover:bg-slate-50">Archivés</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="ucp-vm-list" class="space-y-3">
|
||||
<div class="ucp-card rounded-2xl p-8 text-center text-sm text-slate-400">Chargement des messages…</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Préférences Boîte Vocale -->
|
||||
<div class="ucp-card rounded-2xl p-5 h-fit space-y-4">
|
||||
<div>
|
||||
<h3 class="text-sm font-bold text-slate-900">Préférences Boîte Vocale</h3>
|
||||
<p id="ucp-vm-prefs-subtitle" class="text-xs text-slate-500 mt-0.5">Poste sélectionné</p>
|
||||
</div>
|
||||
<form id="ucp-vm-prefs-form" class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-700 mb-1">Nouveau code PIN (4 à 12 chiffres)</label>
|
||||
<input type="password" name="pin" inputmode="numeric" pattern="\d{4,12}" placeholder="Laisser vide pour conserver l'actuel"
|
||||
class="w-full px-3 py-2 rounded-xl bg-slate-50 border border-slate-300 text-xs text-slate-900 font-mono focus:bg-white focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500 focus:outline-none">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-700 mb-1">E-mail de notification</label>
|
||||
<input type="email" name="email" placeholder="prenom@entreprise.fr"
|
||||
class="w-full px-3 py-2 rounded-xl bg-slate-50 border border-slate-300 text-xs text-slate-900 focus:bg-white focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500 focus:outline-none">
|
||||
</div>
|
||||
<button type="submit" id="ucp-vm-prefs-btn"
|
||||
class="w-full py-2 px-4 rounded-xl bg-indigo-600 hover:bg-indigo-700 text-white text-xs font-semibold transition">
|
||||
Enregistrer les préférences
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ONGLET 3 : MES APPELS (MINI-CDR PERSONNEL) -->
|
||||
<section id="ucp-panel-cdr" class="ucp-panel hidden space-y-5">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-lg font-bold text-slate-900">Mon Journal d'Appels</h2>
|
||||
<p class="text-xs text-slate-500">Historique filtré sur vos postes liés uniquement.</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs">
|
||||
<span class="px-3 py-1 rounded-full bg-white border border-slate-200 font-semibold text-slate-700">Total : <strong id="ucp-cdr-total">0</strong></span>
|
||||
<span class="px-3 py-1 rounded-full bg-emerald-50 border border-emerald-200 font-semibold text-emerald-700">Répondus : <strong id="ucp-cdr-answered">0</strong></span>
|
||||
<span class="px-3 py-1 rounded-full bg-rose-50 border border-rose-200 font-semibold text-rose-700">Manqués : <strong id="ucp-cdr-missed">0</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ucp-card rounded-2xl p-4 grid grid-cols-1 sm:grid-cols-4 gap-3">
|
||||
<select id="ucp-cdr-direction" onchange="loadPersonalCdr()" class="px-3 py-2 rounded-xl bg-slate-50 border border-slate-300 text-xs font-medium text-slate-800">
|
||||
<option value="all">Toutes directions</option>
|
||||
<option value="inbound">Appels entrants</option>
|
||||
<option value="outbound">Appels sortants</option>
|
||||
<option value="internal">Appels internes</option>
|
||||
</select>
|
||||
<select id="ucp-cdr-disposition" onchange="loadPersonalCdr()" class="px-3 py-2 rounded-xl bg-slate-50 border border-slate-300 text-xs font-medium text-slate-800">
|
||||
<option value="all">Tous statuts</option>
|
||||
<option value="ANSWERED">Répondus uniquement</option>
|
||||
<option value="MISSED">Manqués / Sans réponse</option>
|
||||
</select>
|
||||
<select id="ucp-cdr-period" onchange="loadPersonalCdr()" class="px-3 py-2 rounded-xl bg-slate-50 border border-slate-300 text-xs font-medium text-slate-800">
|
||||
<option value="all">Tout l'historique</option>
|
||||
<option value="today">Aujourd'hui</option>
|
||||
<option value="week">7 derniers jours</option>
|
||||
<option value="month">30 derniers jours</option>
|
||||
</select>
|
||||
<input type="text" id="ucp-cdr-search" oninput="debounceLoadCdr()" placeholder="Rechercher un numéro ou nom…"
|
||||
class="px-3 py-2 rounded-xl bg-slate-50 border border-slate-300 text-xs text-slate-900 focus:bg-white focus:outline-none focus:border-indigo-500">
|
||||
</div>
|
||||
|
||||
<!-- Vue mobile style journal d'appels iPhone (< 768px) -->
|
||||
<div id="ucp-cdr-mobile-list" class="md:hidden space-y-2.5">
|
||||
<div class="ucp-card rounded-2xl p-6 text-center text-xs text-slate-400">Chargement…</div>
|
||||
</div>
|
||||
|
||||
<!-- Vue tableau complète sur ordinateur (>= 768px) -->
|
||||
<div class="hidden md:block ucp-card rounded-2xl overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-left text-xs">
|
||||
<thead class="bg-slate-50 border-b border-slate-200 text-slate-500 uppercase text-[11px]">
|
||||
<tr>
|
||||
<th class="py-3 px-4">Date & Heure</th>
|
||||
<th class="py-3 px-4">Direction</th>
|
||||
<th class="py-3 px-4">De</th>
|
||||
<th class="py-3 px-4">Vers</th>
|
||||
<th class="py-3 px-4">Durée</th>
|
||||
<th class="py-3 px-4">Statut</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="ucp-cdr-tbody" class="divide-y divide-slate-100">
|
||||
<tr><td colspan="6" class="py-8 text-center text-slate-400">Chargement…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ONGLET 4 : ANNUAIRE & PROFIL -->
|
||||
<section id="ucp-panel-phonebook" class="ucp-panel hidden space-y-5">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-lg font-bold text-slate-900">Annuaire de l'Entreprise</h2>
|
||||
<p class="text-xs text-slate-500">Consultez tous les contacts et mettez à jour la photo ou l'e-mail de vos propres postes.</p>
|
||||
</div>
|
||||
<input type="text" id="ucp-pb-search" oninput="renderPhonebookGrid()" placeholder="Rechercher un nom, poste, email…"
|
||||
class="w-full sm:w-72 px-3.5 py-2 rounded-xl bg-white border border-slate-300 text-xs text-slate-900 focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500 focus:outline-none">
|
||||
</div>
|
||||
|
||||
<div id="ucp-pb-group-tabs" class="flex flex-wrap items-center gap-2"></div>
|
||||
|
||||
<div id="ucp-pb-grid" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div class="ucp-card rounded-2xl p-8 text-center text-sm text-slate-400 col-span-full">Chargement de l'annuaire…</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ONGLET 5 : NOTICE TÉLÉPHONIE, CODES DE FONCTION & EXPORT PDF -->
|
||||
<section id="ucp-panel-guide" class="ucp-panel hidden space-y-5">
|
||||
<!-- Barre d'actions écran uniquement -->
|
||||
<div class="no-print flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-lg font-bold text-slate-900">Notice d'Utilisation & Codes de Fonction</h2>
|
||||
<p class="text-xs text-slate-500">Mémo des raccourcis téléphoniques actifs et fiche pratique imprimable en PDF.</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2.5">
|
||||
<input type="text" id="ucp-guide-search" oninput="renderFeatureGuide()" placeholder="Filtrer un code ou service (*97, renvoi…)"
|
||||
class="w-full sm:w-64 px-3.5 py-2 rounded-xl bg-white border border-slate-300 text-xs text-slate-900 focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500 focus:outline-none">
|
||||
<button type="button" onclick="printFeatureGuide()"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 rounded-xl bg-indigo-600 hover:bg-indigo-700 text-white text-xs font-semibold shadow-sm shadow-indigo-500/20 transition">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z"/></svg>
|
||||
<span>Imprimer / Exporter en PDF</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Document A4 Propre & Imprimable -->
|
||||
<div id="ucp-guide-printable" class="ucp-card rounded-3xl p-6 sm:p-8 space-y-6">
|
||||
<!-- En-tête Corporate du document -->
|
||||
<div class="ucp-print-avoid-break ucp-print-row rounded-2xl bg-gradient-to-r from-slate-900 via-indigo-950 to-slate-900 text-white p-5 sm:p-6 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-4 min-w-0">
|
||||
<div class="h-14 w-14 sm:h-16 sm:w-16 rounded-2xl bg-white p-2 flex items-center justify-center shrink-0 shadow-md">
|
||||
<?php if ($ucpEffectiveLogo !== ''): ?>
|
||||
<img src="<?= htmlspecialchars($ucpEffectiveLogo) ?>" alt="Logo" class="max-h-full max-w-full object-contain">
|
||||
<?php else: ?>
|
||||
<img src="/assets/logo.png?v=1" alt="NexusPBX" class="max-h-full max-w-full object-contain">
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="text-[10px] font-bold uppercase tracking-widest text-indigo-300">Fiche Mémo Utilisateur • Téléphonie IP</div>
|
||||
<h3 class="text-lg sm:text-xl font-extrabold tracking-tight truncate"><?= htmlspecialchars($companyName ?: 'NexusPBX') ?></h3>
|
||||
<p class="text-xs text-slate-300 mt-0.5">Guide des codes de fonction, messagerie vocale et renvois d'appel</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sm:text-right bg-white/10 border border-white/15 rounded-xl px-4 py-2.5 text-xs space-y-1 shrink-0">
|
||||
<div class="text-slate-300">Utilisateur : <strong class="text-white"><?= htmlspecialchars((string)($currentUser['display_name'] ?: $currentUser['username'])) ?></strong></div>
|
||||
<div class="text-slate-300">Poste(s) : <strong id="ucp-guide-user-exts" class="font-mono text-indigo-200">—</strong></div>
|
||||
<div class="text-[10px] text-slate-400">Édité le <span id="ucp-guide-print-date"><?= date('d/m/Y') ?></span> • v<?= htmlspecialchars($appVersion) ?></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Encarts Notice Pratique Rapide -->
|
||||
<div id="ucp-guide-quickcards" class="ucp-print-3col grid grid-cols-1 md:grid-cols-3 gap-4"></div>
|
||||
|
||||
<!-- Liste complète structurée par catégorie -->
|
||||
<div id="ucp-guide-categories" class="space-y-4">
|
||||
<div class="text-center text-sm text-slate-400 py-8">Chargement des codes de fonction…</div>
|
||||
</div>
|
||||
|
||||
<!-- Pied de document -->
|
||||
<div class="ucp-print-avoid-break pt-4 border-t border-slate-200 flex flex-col sm:flex-row sm:items-center justify-between gap-2 text-[11px] text-slate-500">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<svg class="w-4 h-4 text-amber-500 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 18v-5.25m0 0a6.01 6.01 0 001.5-.189m-1.5.189a6.01 6.01 0 01-1.5-.189m3.75 7.478a12.06 12.06 0 01-4.5 0m3.75 2.383a14.406 14.406 0 01-3 0M14.25 18v-.192c0-.983.658-1.823 1.508-2.316a7.5 7.5 0 10-7.517 0c.85.493 1.509 1.333 1.509 2.316V18"/></svg>
|
||||
<span><strong>Astuce :</strong> composez ces codes directement sur votre combiné IP, ou pilotez DND / renvois / messagerie depuis le portail UCP.</span>
|
||||
</div>
|
||||
<div class="font-semibold text-slate-600"><?= htmlspecialchars($companyName ?: 'NexusPBX') ?> — Document interne</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ONGLET 6 : MON COMPTE -->
|
||||
<section id="ucp-panel-account" class="ucp-panel hidden space-y-5">
|
||||
<div>
|
||||
<h2 class="text-lg font-bold text-slate-900">Mon Compte UCP</h2>
|
||||
<p class="text-xs text-slate-500">Sécurisez votre accès personnel au portail utilisateur.</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div class="ucp-card rounded-2xl p-6 space-y-4">
|
||||
<h3 class="text-sm font-bold text-slate-900">Informations de session</h3>
|
||||
<dl class="space-y-2.5 text-xs">
|
||||
<div class="flex justify-between py-2 border-b border-slate-100">
|
||||
<dt class="text-slate-500">Identifiant</dt>
|
||||
<dd class="font-mono font-semibold text-slate-900"><?= htmlspecialchars((string)$currentUser['username']) ?></dd>
|
||||
</div>
|
||||
<div class="flex justify-between py-2 border-b border-slate-100">
|
||||
<dt class="text-slate-500">Nom affiché</dt>
|
||||
<dd class="font-semibold text-slate-900"><?= htmlspecialchars((string)($currentUser['display_name'] ?: $currentUser['username'])) ?></dd>
|
||||
</div>
|
||||
<div class="flex justify-between py-2 border-b border-slate-100">
|
||||
<dt class="text-slate-500">Postes autorisés</dt>
|
||||
<dd id="ucp-account-exts" class="font-mono font-semibold text-indigo-600">—</dd>
|
||||
</div>
|
||||
<div class="flex justify-between py-2">
|
||||
<dt class="text-slate-500">Dernière connexion</dt>
|
||||
<dd class="text-slate-700"><?= htmlspecialchars((string)($currentUser['last_login_at'] ?? 'Première connexion')) ?></dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="ucp-card rounded-2xl p-6 space-y-4">
|
||||
<h3 class="text-sm font-bold text-slate-900">Changer mon mot de passe UCP</h3>
|
||||
<form id="ucp-password-form" class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-700 mb-1">Mot de passe actuel</label>
|
||||
<input type="password" name="current_password" required autocomplete="current-password"
|
||||
class="w-full px-3 py-2 rounded-xl bg-slate-50 border border-slate-300 text-xs text-slate-900 focus:bg-white focus:outline-none focus:border-indigo-500">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-700 mb-1">Nouveau mot de passe (min. 6 caractères)</label>
|
||||
<input type="password" name="new_password" required minlength="6" autocomplete="new-password"
|
||||
class="w-full px-3 py-2 rounded-xl bg-slate-50 border border-slate-300 text-xs text-slate-900 focus:bg-white focus:outline-none focus:border-indigo-500">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-700 mb-1">Confirmer le nouveau mot de passe</label>
|
||||
<input type="password" name="confirm_password" required minlength="6" autocomplete="new-password"
|
||||
class="w-full px-3 py-2 rounded-xl bg-slate-50 border border-slate-300 text-xs text-slate-900 focus:bg-white focus:outline-none focus:border-indigo-500">
|
||||
</div>
|
||||
<button type="submit" class="w-full py-2.5 px-4 rounded-xl bg-indigo-600 hover:bg-indigo-700 text-white text-xs font-semibold transition">
|
||||
Mettre à jour mon mot de passe
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- MODALE ÉDITION PROFIL ANNUAIRE (PHOTO + EMAIL UNIQUEMENT) -->
|
||||
<div id="ucp-profile-modal" class="hidden fixed inset-0 z-50 bg-slate-900/40 backdrop-blur-sm flex items-center justify-center p-4">
|
||||
<div class="w-full max-w-md bg-white rounded-2xl border border-slate-200 shadow-2xl p-6 space-y-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-base font-bold text-slate-900">Modifier ma fiche annuaire</h3>
|
||||
<p id="ucp-profile-modal-sub" class="text-xs text-slate-500">Poste —</p>
|
||||
</div>
|
||||
<button type="button" onclick="closeProfileModal()" class="text-slate-400 hover:text-slate-700 text-lg font-bold">✕</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4 p-3.5 rounded-xl bg-slate-50 border border-slate-200">
|
||||
<img id="ucp-profile-preview" src="" alt="Avatar" class="w-14 h-14 rounded-full object-cover border border-slate-200 bg-white hidden">
|
||||
<div id="ucp-profile-initials" class="w-14 h-14 rounded-full bg-indigo-100 text-indigo-700 font-bold flex items-center justify-center text-lg">?</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<label class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-white border border-slate-300 hover:bg-slate-100 text-xs font-semibold text-slate-700 cursor-pointer transition">
|
||||
<svg class="w-4 h-4 text-slate-500 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6.827 6.175A2.31 2.31 0 015.186 7.23c-.38.054-.757.112-1.134.175C2.999 7.58 2.25 8.507 2.25 9.574V18a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9.574c0-1.067-.75-1.994-1.802-2.169a47.865 47.865 0 00-1.134-.175 2.31 2.31 0 01-1.64-1.055l-.822-1.316a2.192 2.192 0 00-1.736-1.039 48.774 48.774 0 00-5.232 0 2.192 2.192 0 00-1.736 1.039l-.821 1.316z"/><path stroke-linecap="round" stroke-linejoin="round" d="M16.5 12.75a4.5 4.5 0 11-9 0 4.5 4.5 0 019 0z"/></svg>
|
||||
<span>Choisir une photo</span>
|
||||
<input type="file" id="ucp-avatar-input" accept="image/jpeg,image/png,image/webp" class="hidden" onchange="uploadSelectedAvatar()">
|
||||
</label>
|
||||
<p class="text-[11px] text-slate-400 mt-1">JPG, PNG ou WebP • 5 Mo max.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="ucp-profile-email-form" class="space-y-3">
|
||||
<input type="hidden" id="ucp-profile-ext" value="">
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-700 mb-1">Adresse e-mail affichée dans l'annuaire</label>
|
||||
<input type="email" id="ucp-profile-email-input" placeholder="prenom.nom@entreprise.fr"
|
||||
class="w-full px-3.5 py-2 rounded-xl bg-slate-50 border border-slate-300 text-xs text-slate-900 focus:bg-white focus:outline-none focus:border-indigo-500">
|
||||
</div>
|
||||
<div class="flex gap-2 pt-2">
|
||||
<button type="button" onclick="closeProfileModal()" class="flex-1 py-2 px-4 rounded-xl border border-slate-200 text-xs font-semibold text-slate-600 hover:bg-slate-50">Fermer</button>
|
||||
<button type="submit" class="flex-1 py-2 px-4 rounded-xl bg-indigo-600 hover:bg-indigo-700 text-white text-xs font-semibold">Enregistrer l'e-mail</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<script>
|
||||
window.UCP_CSRF_TOKEN = <?= json_encode($csrfToken) ?>;
|
||||
window.UCP_IS_AUTH = <?= json_encode($isAuth) ?>;
|
||||
</script>
|
||||
<script src="/assets/ucp.js?v=<?= file_exists(__DIR__ . '/assets/ucp.js') ? filemtime(__DIR__ . '/assets/ucp.js') : time() ?>"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+25
-13
@@ -1,18 +1,30 @@
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"codename": "Pulsar",
|
||||
"release_date": "2026-11-11",
|
||||
"version": "2.7.3",
|
||||
"codename": "Pulsar UCP",
|
||||
"release_date": "2026-11-12",
|
||||
"min_php_version": "8.2",
|
||||
"changelog": [
|
||||
"NexusPBX 2.0 « Pulsar » : Version Majeure Haute Disponibilité Télécom & Résilience",
|
||||
"Haute Disponibilité (HA) : cluster actif/passif avec IP Virtuelle flottante (VIP) et consensus Keepalived VRRP",
|
||||
"HA Télécom : liaison stricte des transports PJSIP sur la VIP et annonce exclusive de la VIP en signalisation/média",
|
||||
"HA Télécom : isolation SIP stricte (iptables REJECT) sur nœud Standby et purge de subscription_persistence AstDB éliminant tout dialogue BLF zombie au failover",
|
||||
"HA Télécom : réveil BLF progressif automatique (check-sync) dès réenregistrement des postes sur le nouveau Master",
|
||||
"HA Données & Sécurité : réplication atomique SQLite (VACUUM INTO), synchro miroir voicemails & sons personnalisés (rsync --delete), propagation des IP bannies Fail2ban et réconciliation automatique au failback (--failback-check)",
|
||||
"Postes Hors Réseau & NAT/TLS : keepalive applicatif 30s (keep_alive_interval), TCP keepalives noyau (20s/5s/3), rtp_timeout, rtp_keepalive et SIP Session Timers (RFC 4028) sur tous les endpoints et trunks",
|
||||
"Routage & Groupes d'Appel : nouvelle destination directe Répondeur (vm-<poste>) en débordement, SVI et routes entrantes",
|
||||
"Sécurité & Téléphonie : détection temps réel des conversations actives avant redémarrage de poste avec confirmation sécurisée",
|
||||
"Paramètres SIP : fiabilisation du rtp_timeout_hold et expiration AOR plafonnée (120s-300s) pour maintenir les tunnels NAT actifs"
|
||||
"Correctif v2.7.3 : conservation de l'adresse email renseignée sur un contact interne de l'Annuaire lors de la synchronisation des postes",
|
||||
"Correctif v2.7.2 : formatage uniforme du changelog dans version.json (suppression de [object Object] dans la fiche de mise à jour)",
|
||||
"Correctif v2.7.1 : suppression de l'erreur Nginx 502 transitoire après mise à jour 1-Clic (pause du polling métriques, sonde multi-essais avant rechargement)",
|
||||
"Nouveauté v2.7.0 : logo spécifique optionnel pour le portail UCP (thème clair), avec repli automatique sur le logo principal puis NexusPBX",
|
||||
"UI v2.6.7 : remplacement de l'ensemble des icônes emoji par des icônes vectorielles SVG uniformes sur Admin & UCP (sans modifier le menu latéral Admin)",
|
||||
"UI v2.6.6 : menu déroulant personnalisé sombre avec icônes SVG Heroicons pour toutes les destinations d'appel (sans emojis natifs)",
|
||||
"UI v2.6.5 : refonte des intitulés de destinations audio (Musique d'attente & Annonce vocale en boucle continue), nouveau wallpaper clair UCP et placeholder générique",
|
||||
"Correctif v2.6.4 : prise en charge multi-groupes dans l'annuaire UCP & Admin (un poste appartenant à plusieurs groupes apparaît bien dans chacun des onglets correspondants)",
|
||||
"UI v2.6.3 : onglets dynamiques par groupe (Tous, Général, Paris, New-York...) avec compteurs dans l'Annuaire UCP",
|
||||
"Correctif v2.6.2 : sélection stricte de <host> GroundWire (NAT si renseigné, sinon VIP HA si cluster actif, sinon IP locale + port d'écoute local)",
|
||||
"UI v2.6.1 : suppression de l'icône smartphone sur les éléments GroundWire et refonte des boutons d'action du tableau Postes (Redémarrer / Modifier / Supprimer)",
|
||||
"Nouveauté v2.6.0 : provisionnement dynamique XML Acrobits GroundWire par poste (option Admin « GroundWire XML », deep link mobile provlinkbs:, téléchargement XML signé HMAC sans session, liens App Store / Google Play dans l'UCP)",
|
||||
"UI v2.5.7 : journal d'appels UCP en cartes style iOS sur mobile (<768px) tout en conservant le tableau 6 colonnes sur ordinateur",
|
||||
"Correctif v2.5.6 : ergonomie mobile iPhone (en-tête compact + dégradé latéral discret sur la barre d'onglets UCP sans scrollbar visible)",
|
||||
"Correctif v2.5.5 : suppression du zoom automatique iOS/iPhone sur les champs (<16px) et blocage du débordement horizontal mobile dans l'UCP",
|
||||
"Correctif v2.5.4 : correction du journal personnel UCP « Mes Appels » (correspondance caller_number/callee_number/canaux PJSIP et filtres Répondus/Manqués)",
|
||||
"Nouveauté v2.5.3 : onglet UCP « Notice & Codes » avec mémo pas-à-pas, codes de fonction dynamiques et export PDF / impression A4 corporate (logo, encarts, poste utilisateur)",
|
||||
"Correctif v2.5.2 : purge systématique try/finally + auto-nettoyage des dossiers temporaires HA /tmp (élimine la saturation tmpfs 100% qui bloquait la réplication)",
|
||||
"Correctif v2.5.1 : auto-déploiement automatique Nginx/Fail2ban UCP même après un git pull manuel",
|
||||
"Correctif v2.5.1 : vérification d'intégrité & restauration SQLite HA via PDO / SQLite3::backup sans dépendance CLI sqlite3 et anti-collision /tmp",
|
||||
"Correctif v2.5.1 : rafraîchissement instantané de la liste des utilisateurs UCP à la création/suppression sans rechargement manuel de la page",
|
||||
"NexusPBX 2.5.0 « Pulsar UCP » (Issue #4) : Portail Utilisateur Isolé (User Control Panel)"
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user