* @copyright 2025-2026 Vateron Media * @link https://github.com/Vateron-Media/XC_VM * @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html */ class DiagnosticsService { /** * Parse SSL certificate info from nginx config or a specific file * * @param string|null $certificate Path to certificate file (auto-detects from nginx if null) * @return array ['serial', 'expiration', 'subject', 'path'] */ public static function getCertificateInfo($certificate = null) { $result = ['serial' => null, 'expiration' => null, 'subject' => null, 'path' => null]; if (!$certificate) { $config = explode("\n", file_get_contents(BIN_PATH . 'nginx/conf/ssl.conf')); foreach ($config as $line) { $rTrimmed = trim($line); if (strncasecmp($rTrimmed, 'ssl_certificate ', 16) === 0) { $certificate = trim(explode(';', substr($rTrimmed, 16), 2)[0]); break; } } } if (!$certificate || !file_exists($certificate)) { return null; } $result['path'] = pathinfo($certificate)['dirname']; $output = []; exec('openssl x509 -serial -enddate -subject -noout -in ' . escapeshellarg($certificate), $output, $returnVar); if ($returnVar !== 0) { return null; } foreach ($output as $line) { if (stripos($line, 'serial=') !== false) { $result['serial'] = trim(explode('serial=', $line)[1]); } elseif (stripos($line, 'subject=') !== false) { $result['subject'] = trim(explode('subject=', $line)[1]); } elseif (stripos($line, 'notAfter=') !== false) { $result['expiration'] = strtotime(trim(explode('notAfter=', $line)[1])); } } return $result; } /** * Check if stream codecs are compatible with the player * * @param array|string $data FFProbe output (array or JSON string) * @param bool $allowHEVC Whether HEVC/H265 + AC3 are allowed * @return bool */ public static function checkCompatibility($data, $allowHEVC = false) { if (!is_array($data)) { $data = json_decode($data, true); } if (!is_array($data) || !isset($data['codecs']) || !is_array($data['codecs'])) { return false; } $audioCodec = $data['codecs']['audio']['codec_name'] ?? null; $videoCodec = $data['codecs']['video']['codec_name'] ?? null; $audioCodecs = ['aac', 'libfdk_aac', 'opus', 'vorbis', 'pcm_s16le', 'mp2', 'mp3', 'flac']; $videoCodecs = ['h264', 'vp8', 'vp9', 'ogg', 'av1']; if ($allowHEVC) { $videoCodecs[] = 'hevc'; $videoCodecs[] = 'h265'; $audioCodecs[] = 'ac3'; } if (!$videoCodec) { return false; } if (!in_array(strtolower($videoCodec), $videoCodecs, true)) { return false; } if ($audioCodec && !in_array(strtolower($audioCodec), $audioCodecs, true)) { return false; } return true; } /** * Download panel logs from database, format them and clear the logs table * * @param \DatabaseHandler $db Database handler (must have ->query(), ->get_rows()) * @return array ['errors' => [...], 'version' => string] * @throws Exception */ public static function downloadPanelLogs($db): array { ini_set('default_socket_timeout', 60); $errors = []; try { $query = "SELECT `type`, `log_message`, `log_extra`, `line`, `date` FROM `panel_logs` WHERE `type` <> 'epg' ORDER BY `date` DESC LIMIT 1000"; $result = $db->query($query); if (!$result) { throw new Exception('Failed to execute database query'); } $allErrors = $db->get_rows() ?: []; foreach ($allErrors as $error) { $errorData = [ 'type' => isset($error['type']) ? htmlspecialchars($error['type'], ENT_QUOTES, 'UTF-8') : 'unknown', 'message' => isset($error['log_message']) ? htmlspecialchars($error['log_message'], ENT_QUOTES, 'UTF-8') : '', 'file' => isset($error['log_extra']) ? htmlspecialchars($error['log_extra'], ENT_QUOTES, 'UTF-8') : '', 'line' => isset($error['line']) ? (int)$error['line'] : 0, 'date' => isset($error['date']) ? (int)$error['date'] : 0, ]; try { if ($errorData['date'] > 0) { $dt = new DateTime('@' . $errorData['date']); $dt->setTimezone(new DateTimeZone('UTC')); $errorData['human_date'] = $dt->format('Y-m-d H:i:s'); } else { $errorData['human_date'] = 'invalid_timestamp'; } } catch (Exception $e) { $errorData['human_date'] = 'conversion_error'; } $errors[] = $errorData; } if (!empty($errors)) { $truncateResult = $db->query('TRUNCATE `panel_logs`;'); if (!$truncateResult) { throw new Exception('Failed to truncate panel logs table'); } } } catch (Exception $e) { throw new Exception('Failed to process panel logs'); } return [ 'errors' => $errors, 'version' => defined('XC_VM_VERSION') ? XC_VM_VERSION : 'unknown', ]; } /** * Submit panel logs to the central API server * * @param \DatabaseHandler $db Database handler * @return string|false API response or false on failure */ public static function submitPanelLogs($db) { ini_set('default_socket_timeout', 60); // Select only logs not yet marked as sent $db->query("SELECT `id`, `type`, `log_message`, `log_extra`, `line`, `date` FROM `panel_logs` WHERE `type` <> 'epg' AND IFNULL(`sent`,0)=0 GROUP BY CONCAT(`type`, `log_message`, `log_extra`) ORDER BY `date` DESC LIMIT 1000;"); $rows = $db->get_rows() ?: []; $rAPI = base64_decode(implode('', [ 'aHR0cDov', 'L3hjdm0u', 'dGVjaC9h', 'cGkvdjEv', 'cmVwb3J0' ])); $errorsForApi = []; $ids = []; foreach ($rows as $row) { $ts = isset($row['date']) ? (int)$row['date'] : 0; $errorsForApi[] = [ 'type' => $row['type'] ?? '', 'log_message' => $row['log_message'] ?? '', 'log_extra' => $row['log_extra'] ?? '', 'line' => isset($row['line']) ? (string)$row['line'] : '', 'date' => $ts > 0 ? gmdate('Y-m-d H:i:s', $ts) : '', ]; if (isset($row['id'])) { $ids[] = (int)$row['id']; } } $payload = json_encode([ 'errors' => $errorsForApi, 'version' => defined('XC_VM_VERSION') ? XC_VM_VERSION : 'unknown', ], JSON_UNESCAPED_UNICODE); $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $rAPI); curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, $payload); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10); curl_setopt($ch, CURLOPT_TIMEOUT, 60); curl_setopt($ch, CURLOPT_HTTPHEADER, [ 'Content-Type: application/json', 'Content-Length: ' . strlen($payload), ]); $response = curl_exec($ch); curl_close($ch); if ($response !== false) { $responseData = json_decode($response, true); if (isset($responseData['status']) && $responseData['status'] === 'success' && !empty($ids)) { // mark sent logs instead of truncating the whole table $idList = implode(',', array_map('intval', $ids)); $db->query("UPDATE `panel_logs` SET `sent` = 1 WHERE `id` IN ($idList);"); } } return $response; } /** * Fetch the list of running process PIDs from a server via the system API. * * @param int $rServerID Server id to query. * @return array Process info keyed/listed as returned by the server. */ public static function getPIDs($rServerID) { $rReturn = array(); $rProcesses = json_decode(ApiClient::systemRequest($rServerID, array('action' => 'get_pids')), true); if (!is_array($rProcesses)) { return $rReturn; } array_shift($rProcesses); foreach ($rProcesses as $rProcess) { $rSplit = explode(' ', preg_replace('!\\s+!', ' ', trim($rProcess))); if ($rSplit[0] == 'xc_vm') { $rUsage = array(0, 0, 0); $rTimer = explode('-', $rSplit[9]); if (1 < count($rTimer)) { $rDays = intval($rTimer[0]); $rTime = $rTimer[1]; } else { $rDays = 0; $rTime = $rTimer[0]; } $rTime = explode(':', $rTime); if (count($rTime) == 3) { $rSeconds = intval($rTime[0]) * 3600 + intval($rTime[1]) * 60 + intval($rTime[2]); } else { if (count($rTime) == 2) { $rSeconds = intval($rTime[0]) * 60 + intval($rTime[1]); } else { $rSeconds = intval($rTime[2]); } } $rUsage[0] = $rSeconds + $rDays * 86400; $rTimer = explode('-', $rSplit[8]); if (1 < count($rTimer)) { $rDays = intval($rTimer[0]); $rTime = $rTimer[1]; } else { $rDays = 0; $rTime = $rTimer[0]; } $rTime = explode(':', $rTime); if (count($rTime) == 3) { $rSeconds = intval($rTime[0]) * 3600 + intval($rTime[1]) * 60 + intval($rTime[2]); } else { if (count($rTime) == 2) { $rSeconds = intval($rTime[0]) * 60 + intval($rTime[1]); } else { $rSeconds = intval($rTime[2]); } } $rUsage[1] = $rSeconds + $rDays * 86400; if ($rUsage[0] != 0) { $rUsage[2] = $rUsage[1] / $rUsage[0] * 100; } else { $rUsage[2] = 0; } $rReturn[] = array('user' => $rSplit[0], 'pid' => $rSplit[1], 'cpu' => $rSplit[2], 'mem' => $rSplit[3], 'vsz' => $rSplit[4], 'rss' => $rSplit[5], 'tty' => $rSplit[6], 'stat' => $rSplit[7], 'time' => $rUsage[1], 'etime' => $rUsage[0], 'load_average' => $rUsage[2], 'command' => implode(' ', array_splice($rSplit, 10, count($rSplit) - 10))); } } return $rReturn; } /** * List NVENC (GPU) encoding processes for a server. * * @param int $rServerID Server id to inspect. * @return array NVENC process details. */ public static function getNVENCProcesses($rServerID) { global $db; $rProcesses = array(); $rServer = ServerRepository::getById($rServerID); $rGPUInfo = json_decode($rServer['gpu_info'], true); if (!is_array($rGPUInfo)) { } else { foreach ($rGPUInfo['gpus'] as $rGPU) { foreach ($rGPU['processes'] as $rProcess) { $rArray = array('pid' => $rProcess['pid'], 'memory' => $rProcess['memory'], 'stream_id' => null); $db->query('SELECT `stream_id` FROM `streams_servers` WHERE `pid` = ? AND `server_id` = ?;', $rProcess['pid'], $rServerID); if (0 >= $db->num_rows()) { } else { $rArray['stream_id'] = $db->get_row()['stream_id']; } $rProcesses[] = $rArray; } } } return $rProcesses; } }