diff --git a/public/api.php b/public/api.php index 7a24031..974fe0c 100644 --- a/public/api.php +++ b/public/api.php @@ -311,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"; @@ -339,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([ @@ -360,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, @@ -383,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, @@ -402,6 +405,7 @@ try { ':vma' => $voicemail_attach, ':vmd' => $voicemail_delete, ':rt' => $ring_time, + ':gwe' => $groundwire_enabled, ]); } diff --git a/public/assets/app.js b/public/assets/app.js index b3bef85..a86558e 100644 --- a/public/assets/app.js +++ b/public/assets/app.js @@ -937,14 +937,18 @@ function renderExtensionsTable() { const vmBadge = Number(ext.voicemail_enabled) === 1 ? `📟 Boîte` : ''; + const gwBadge = Number(ext.groundwire_enabled) === 1 + ? `📱 GroundWire` + : ''; return ` ${escapeHtml(ext.extension)} -
+
${escapeHtml(ext.name)} ${vmBadge} + ${gwBadge}
${groupBadges}
@@ -1027,6 +1031,9 @@ function openExtensionModal(ext = null) { }); syncEncryptionToggle(transport, ext ? (ext.encryption ?? (transport === 'tls' ? 1 : 0)) : (transport === 'tls' ? 1 : 0)); + const gwCheckbox = document.getElementById('ext-groundwire-enabled'); + if (gwCheckbox) gwCheckbox.checked = ext ? (Number(ext.groundwire_enabled) === 1) : false; + // Réinitialisation de l'onglet actif vers "Général" switchExtModalTab('general'); @@ -3829,6 +3836,7 @@ document.addEventListener('DOMContentLoaded', () => { payload.voicemail_attach = document.getElementById('ext-vm-attach')?.checked ? 1 : 0; payload.voicemail_delete = document.getElementById('ext-vm-delete')?.checked ? 1 : 0; payload.ring_time = Number(payload.ring_time || 20); + payload.groundwire_enabled = document.getElementById('ext-groundwire-enabled')?.checked ? 1 : 0; try { const res = await apiCall('save_extension', payload); diff --git a/public/index.php b/public/index.php index 6ee4b7f..5a3ef73 100644 --- a/public/index.php +++ b/public/index.php @@ -3263,6 +3263,20 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')

Le mode optionnel négocie le SRTP automatiquement tout en évitant les échecs d'appels si le poste IP a le SRTP désactivé.

+ +
Appartenance aux Groupes d'Extensions (Sites & Droits) diff --git a/src/Database.php b/src/Database.php index c352ace..50f581b 100644 --- a/src/Database.php +++ b/src/Database.php @@ -48,7 +48,8 @@ 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 ucp_https_port, fail2ban_ucp_maxretry FROM settings LIMIT 1"); + self::$instance->query("SELECT ucp_https_port, fail2ban_ucp_maxretry, groundwire_secret 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(); @@ -117,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 ); @@ -439,12 +441,20 @@ class Database '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 @@ -487,6 +497,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)) { diff --git a/src/UcpAuth.php b/src/UcpAuth.php index 1fd7c31..f2e959f 100644 --- a/src/UcpAuth.php +++ b/src/UcpAuth.php @@ -159,7 +159,7 @@ class UcpAuth $linkedSet = array_values(array_unique(array_map('strval', $linked))); $extRows = $db->query(' - SELECT id, extension, name, transport, encryption, voicemail_enabled, voicemail_email, voicemail_pin, ring_time + 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 diff --git a/src/UcpManager.php b/src/UcpManager.php index 09134fa..9632dbc 100644 --- a/src/UcpManager.php +++ b/src/UcpManager.php @@ -162,7 +162,7 @@ class UcpManager /** * Retourne l'état live (en ligne, latence, IP, DND, CF, CFU, compteurs messagerie) des extensions autorisées. */ - public static function getExtensionsState(PDO $db, array $allowedExts): array + public static function getExtensionsState(PDO $db, array $allowedExts, ?int $ucpUserId = null): array { $extNums = array_map(fn($e) => (string)$e['extension'], $allowedExts); if (empty($extNums)) { @@ -243,25 +243,38 @@ class UcpManager $vmCounts = !empty($ext['voicemail_enabled']) ? self::countVoicemails($num) : ['inbox' => 0, 'old' => 0]; $pb = $pbByExtId[$extId] ?? []; + $gwEnabled = !empty($ext['groundwire_enabled']) ? 1 : 0; + $gwXmlUrl = ''; + $gwDeepLink = ''; + if ($gwEnabled === 1 && $ucpUserId !== null && $ucpUserId > 0) { + $gwXmlUrl = self::buildGroundwireXmlUrl($db, $extId, $ucpUserId); + if ($gwXmlUrl !== '') { + $gwDeepLink = 'provlinkbs:' . preg_replace('#^https?://#i', '', $gwXmlUrl); + } + } + $result[] = [ - 'id' => $extId, - 'extension' => $num, - 'name' => (string)$ext['name'], - 'transport' => (string)$ext['transport'], - 'online' => $cInfo['online'], - 'status_label' => $cInfo['status'], - 'rtt_ms' => $cInfo['rtt_ms'], - 'ip' => $cInfo['ip'], - 'user_agent' => $cInfo['user_agent'], - 'dnd' => strtoupper((string)($dndMap[$num] ?? '')) === 'YES', - 'cf' => (string)($cfMap[$num] ?? ''), - 'cfu' => (string)($cfuMap[$num] ?? ''), - 'voicemail_enabled' => (int)$ext['voicemail_enabled'], - 'voicemail_email' => (string)($ext['voicemail_email'] ?? ''), - 'vm_inbox_count' => $vmCounts['inbox'], - 'vm_old_count' => $vmCounts['old'], - 'profile_email' => (string)($pb['email'] ?? ''), - 'photo_url' => (string)($pb['photo_url'] ?? ''), + 'id' => $extId, + 'extension' => $num, + 'name' => (string)$ext['name'], + 'transport' => (string)$ext['transport'], + 'online' => $cInfo['online'], + 'status_label' => $cInfo['status'], + 'rtt_ms' => $cInfo['rtt_ms'], + 'ip' => $cInfo['ip'], + 'user_agent' => $cInfo['user_agent'], + 'dnd' => strtoupper((string)($dndMap[$num] ?? '')) === 'YES', + 'cf' => (string)($cfMap[$num] ?? ''), + 'cfu' => (string)($cfuMap[$num] ?? ''), + 'voicemail_enabled' => (int)$ext['voicemail_enabled'], + 'voicemail_email' => (string)($ext['voicemail_email'] ?? ''), + 'vm_inbox_count' => $vmCounts['inbox'], + 'vm_old_count' => $vmCounts['old'], + 'profile_email' => (string)($pb['email'] ?? ''), + 'photo_url' => (string)($pb['photo_url'] ?? ''), + 'groundwire_enabled' => $gwEnabled, + 'groundwire_xml_url' => $gwXmlUrl, + 'groundwire_deeplink_url' => $gwDeepLink, ]; } @@ -790,6 +803,157 @@ class UcpManager } } + // ========================================================================= + // 7. PROVISIONNEMENT XML ACROBITS GROUNDWIRE + // ========================================================================= + + /** + * Récupère ou initialise la clé secrète HMAC utilisée pour signer les URL XML GroundWire sans session. + */ + public static function getGroundwireSecret(PDO $db): string + { + $secret = trim((string)($db->query('SELECT groundwire_secret FROM settings WHERE id = 1')->fetchColumn() ?: '')); + if ($secret === '') { + $secret = bin2hex(random_bytes(32)); + $db->prepare('UPDATE settings SET groundwire_secret = :s WHERE id = 1')->execute([':s' => $secret]); + } + return $secret; + } + + public static function buildGroundwireToken(PDO $db, int $extId, int $ucpUserId): string + { + return hash_hmac('sha256', "{$extId}-{$ucpUserId}", self::getGroundwireSecret($db)); + } + + public static function verifyGroundwireToken(PDO $db, int $extId, int $ucpUserId, string $token): bool + { + if ($extId <= 0 || $ucpUserId <= 0 || !preg_match('/^[a-f0-9]{64}$/i', $token)) { + return false; + } + return hash_equals(self::buildGroundwireToken($db, $extId, $ucpUserId), strtolower($token)); + } + + public static function buildGroundwireXmlUrl(PDO $db, int $extId, int $ucpUserId): string + { + $rawHost = (string)($_SERVER['HTTP_X_FORWARDED_HOST'] ?? $_SERVER['HTTP_HOST'] ?? ''); + $host = trim(explode(',', $rawHost)[0] ?? ''); + if ($host === '') { + return ''; + } + + $fwdProto = strtolower(trim(explode(',', (string)($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? ''))[0] ?? '')); + $isHttps = ($fwdProto === 'https') + || (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') + || ((int)($_SERVER['SERVER_PORT'] ?? 0) === 443); + $scheme = $isHttps ? 'https' : 'http'; + + $scriptDir = rtrim(str_replace('\\', '/', dirname((string)($_SERVER['SCRIPT_NAME'] ?? '/api.php'))), '/'); + $path = $scriptDir . '/download_groundwire.php'; + $token = self::buildGroundwireToken($db, $extId, $ucpUserId); + + return "{$scheme}://{$host}{$path}?ext_id={$extId}&uid={$ucpUserId}&token={$token}"; + } + + /** + * Génère à la volée le XML pour Acrobits GroundWire à partir des paramètres de l'extension. + * + * @return array{filename: string, xml: string} + */ + public static function buildGroundwireXml(PDO $db, int $extId): array + { + $stmt = $db->prepare(' + SELECT id, extension, name, secret, transport, encryption, enabled, groundwire_enabled + FROM extensions + WHERE id = :id + LIMIT 1 + '); + $stmt->execute([':id' => $extId]); + $extRow = $stmt->fetch(PDO::FETCH_ASSOC); + + if (!$extRow || (int)$extRow['enabled'] !== 1 || (int)$extRow['groundwire_enabled'] !== 1) { + throw new InvalidArgumentException("Provisionnement GroundWire non activé pour ce poste."); + } + + $settings = $db->query(' + SELECT domain, external_ip, letsencrypt_domain, udp_port, tls_port, external_udp_port, external_tls_port + FROM settings + WHERE id = 1 + ')->fetch(PDO::FETCH_ASSOC) ?: []; + + $haVip = ''; + try { + $cl = $db->query('SELECT enabled, virtual_ip FROM cluster_settings WHERE id = 1')->fetch(PDO::FETCH_ASSOC) ?: []; + if (!empty($cl['enabled']) && !empty($cl['virtual_ip'])) { + $haVip = trim(explode('/', (string)$cl['virtual_ip'])[0] ?? ''); + } + } catch (\Throwable) {} + + $sipHost = trim((string)($settings['external_ip'] ?? '')); + if ($sipHost === '') { + $sipHost = trim((string)($settings['letsencrypt_domain'] ?? '')); + } + if ($sipHost === '' && $haVip !== '') { + $sipHost = $haVip; + } + if ($sipHost === '') { + $rawHttpHost = trim(explode(',', (string)($_SERVER['HTTP_X_FORWARDED_HOST'] ?? $_SERVER['HTTP_HOST'] ?? ''))[0] ?? ''); + if ($rawHttpHost !== '') { + $parsedHost = parse_url("http://{$rawHttpHost}", PHP_URL_HOST); + $sipHost = is_string($parsedHost) && $parsedHost !== '' ? $parsedHost : $rawHttpHost; + } + } + if ($sipHost === '') { + $sipHost = trim((string)($settings['domain'] ?? '')) ?: '127.0.0.1'; + } + + $isTls = (($extRow['transport'] ?? 'udp') === 'tls'); + $transport = $isTls ? 'tls' : 'udp'; + $sipPort = $isTls + ? (((int)($settings['external_tls_port'] ?? 0) > 0) ? (int)$settings['external_tls_port'] : ((int)($settings['tls_port'] ?? 5061) ?: 5061)) + : (((int)($settings['external_udp_port'] ?? 0) > 0) ? (int)$settings['external_udp_port'] : ((int)($settings['udp_port'] ?? 5060) ?: 5060)); + + $encMode = (int)($extRow['encryption'] ?? 0); + $sdes = ($isTls || $encMode > 0) ? 'enabled' : 'disabled'; + + $vmCode = '*97'; + try { + $fc = $db->query("SELECT code FROM feature_codes WHERE feature_key = 'voicemail_own' AND enabled = 1 LIMIT 1")->fetchColumn(); + if (is_string($fc) && trim($fc) !== '') { + $vmCode = trim($fc); + } + } catch (\Throwable) {} + + $esc = static fn(string $v): string => htmlspecialchars($v, ENT_XML1 | ENT_QUOTES, 'UTF-8'); + + $extNum = (string)$extRow['extension']; + $lineName = trim((string)($extRow['name'] ?? '')) ?: $extNum; + $password = (string)($extRow['secret'] ?? ''); + $hostPort = "{$sipHost}:{$sipPort}"; + + $xml = "\n" + . "\n" + . " " . $esc($lineName) . "\n" + . " " . $esc($extNum) . "\n" + . " " . $esc($password) . "\n" + . " " . $esc($hostPort) . "\n" + . " " . $esc($transport) . "\n" + . " " . $esc($sdes) . "\n" + . " " . $esc($sdes) . "\n" + . " 1\n" + . " " . $esc($vmCode) . "\n" + . " 1\n" + . " off\n" + . " auto\n" + . "\n"; + + $safeExt = preg_replace('/[^a-zA-Z0-9_-]/', '', $extNum) ?: (string)$extId; + + return [ + 'filename' => "groundwire-{$safeExt}.xml", + 'xml' => $xml, + ]; + } + // ========================================================================= // HELPERS VALIDATION // ========================================================================= diff --git a/ucp/api.php b/ucp/api.php index d21d692..a99be63 100644 --- a/ucp/api.php +++ b/ucp/api.php @@ -114,7 +114,7 @@ try { 'company_logo' => (string)($branding['company_logo'] ?? ''), ], 'csrf_token' => UcpAuth::getCsrfToken(), - 'extensions' => UcpManager::getExtensionsState($db, $allowedExts), + 'extensions' => UcpManager::getExtensionsState($db, $allowedExts, $ucpUserId), 'forward_targets' => UcpManager::getForwardTargets($db), 'feature_codes' => UcpManager::getFeatureCodesForUser($db), ], @@ -130,7 +130,7 @@ try { ]); echo json_encode([ 'success' => true, - 'extensions' => UcpManager::getExtensionsState($db, $allowedExts), + 'extensions' => UcpManager::getExtensionsState($db, $allowedExts, $ucpUserId), 'message' => "Paramètres d'appel du poste {$ext} appliqués en direct.", ]); break; diff --git a/ucp/assets/ucp.js b/ucp/assets/ucp.js index b05cbf7..b5374a3 100644 --- a/ucp/assets/ucp.js +++ b/ucp/assets/ucp.js @@ -351,6 +351,44 @@ function renderExtensionsGrid() {
+ ${(Number(ext.groundwire_enabled) === 1 && ext.groundwire_deeplink_url && ext.groundwire_xml_url) ? ` +
+ +
+ ` : ''}
`; }).join(''); diff --git a/ucp/download_groundwire.php b/ucp/download_groundwire.php new file mode 100644 index 0000000..ef61c2a --- /dev/null +++ b/ucp/download_groundwire.php @@ -0,0 +1,89 @@ + 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."; +} diff --git a/version.json b/version.json index 2e8d610..f7a2a04 100644 --- a/version.json +++ b/version.json @@ -1,9 +1,10 @@ { - "version": "2.5.7", + "version": "2.6.0", "codename": "Pulsar UCP", - "release_date": "2026-11-11", + "release_date": "2026-11-12", "min_php_version": "8.2", "changelog": [ + "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",