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) ? `
+