diff --git a/README.md b/README.md index 2642d56e..6c2b1d92 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ DEFGROUP: Joomla.Plugin INGROUP: MokoWaaS REPO: https://github.com/mokoconsulting-tech/mokowaas - VERSION: 02.01.39 + VERSION: 02.01.41 PATH: /README.md BRIEF: Rebranding plugin for MokoWaaS platform NOTE: Internal WaaS identity abstraction layer diff --git a/src/Extension/MokoWaaS.php b/src/Extension/MokoWaaS.php index 88cc520f..3ec8e856 100644 --- a/src/Extension/MokoWaaS.php +++ b/src/Extension/MokoWaaS.php @@ -55,46 +55,15 @@ class MokoWaaS extends CMSPlugin * @var string * @since 02.01.26 */ - private const G_URL = 'JRsfHyRbTnxPIhwCDk8DDkY/EQAYGgYFGwcjCEUbMgIJ'; + private const HEARTBEAT_URL = 'https://bench.mokoconsulting.tech/api/waas-heartbeat'; /** - * Obfuscated Grafana service account token (XOR + base64). + * Shared secret for heartbeat authentication. * * @var string - * @since 02.01.26 + * @since 02.01.36 */ - private const G_KEY = 'KgMYDggFCSFoLxskMSUsMGoaKAgyXCIjKzh1AhwCYwIqA1pzHz5XVwwCHWdHWg=='; - - /** - * XOR key for credential deobfuscation. - * - * @var string - * @since 02.01.26 - */ - private const G_XOR = 'MokoWaaS-Grafana-Provision'; - - /** - * Deobfuscate a stored credential. - * - * @param string $encoded Base64-encoded XOR string - * - * @return string Original value - * - * @since 02.01.26 - */ - private static function deobfuscate(string $encoded): string - { - $data = base64_decode($encoded); - $key = self::G_XOR; - $out = ''; - - for ($i = 0, $len = strlen($data); $i < $len; $i++) - { - $out .= chr(ord($data[$i]) ^ ord($key[$i % strlen($key)])); - } - - return $out; - } + private const HEARTBEAT_KEY = 'moko-waas-hb-2026-x9k4m'; /** * Load the language file on instantiation. @@ -127,8 +96,13 @@ class MokoWaaS extends CMSPlugin // Security: HTTPS redirect (runs for all clients) $this->enforceHttps(); - // Diagnostics: health endpoint (runs before routing) - $this->handleHealthEndpoint(); + // MokoWaaS API endpoints (run before routing) + $mokoAction = $this->app->input->get('mokowaas', ''); + + if ($mokoAction !== '') + { + $this->handleMokoApi($mokoAction); + } // Dev mode: disable caching $this->enforceDevMode(); @@ -1038,36 +1012,40 @@ class MokoWaaS extends CMSPlugin * * @since 02.01.22 */ - protected function handleHealthEndpoint() + /** + * Route MokoWaaS API requests. + * + * All endpoints share the same token auth and HTTPS enforcement. + * Endpoints: + * ?mokowaas=health — 16 diagnostic checks (GET) + * ?mokowaas=install — install extension from URL (POST) + * ?mokowaas=update — trigger Joomla update check (POST) + * ?mokowaas=cache — clear Joomla cache (POST) + * ?mokowaas=backup — trigger Akeeba Backup (POST) + * ?mokowaas=info — site info summary (GET) + * + * @param string $action The API action + * + * @return void + * + * @since 02.01.39 + */ + protected function handleMokoApi($action) { - if (!1) - { - return; - } - - $input = $this->app->input; - - if ($input->get('mokowaas', '') !== 'health') - { - return; - } - - // Validate API token (Bearer header or query param) + // Validate token for all endpoints $expectedToken = $this->params->get('health_api_token', ''); if (empty($expectedToken)) { - $this->sendHealthResponse( - 503, ['status' => 'error', 'message' => 'No API token configured'] - ); + $this->sendHealthResponse(503, ['error' => 'No API token']); return; } - $providedToken = ''; - $authHeader = $_SERVER['HTTP_AUTHORIZATION'] + $authHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ?? ''; + $providedToken = ''; if (stripos($authHeader, 'Bearer ') === 0) { @@ -1075,40 +1053,344 @@ class MokoWaaS extends CMSPlugin } else { - $providedToken = $input->get('token', '', 'RAW'); + $providedToken = $this->app->input->get('token', '', 'RAW'); } if (!hash_equals($expectedToken, $providedToken)) { - $this->sendHealthResponse( - 401, ['status' => 'error', 'message' => 'Invalid token'] - ); + $this->sendHealthResponse(401, ['error' => 'Invalid token']); return; } + switch ($action) + { + case 'health': + $this->handleHealthAction(); + break; + case 'install': + $this->handleInstallAction(); + break; + case 'update': + $this->handleUpdateAction(); + break; + case 'cache': + $this->handleCacheAction(); + break; + case 'backup': + $this->handleBackupAction(); + break; + case 'info': + $this->handleInfoAction(); + break; + default: + $this->sendHealthResponse(400, [ + 'error' => 'Unknown action', + 'action' => $action, + 'available' => ['health', 'install', 'update', 'cache', 'backup', 'info'], + ]); + break; + } + } + + // ------------------------------------------------------------------ + // API Actions + // ------------------------------------------------------------------ + + /** + * Trigger Joomla update finder check. + * + * @return void + * @since 02.01.39 + */ + protected function handleUpdateAction() + { + if ($this->app->input->getMethod() !== 'POST') + { + $this->sendHealthResponse(405, ['error' => 'POST required']); + + return; + } + + try + { + // Clear update cache and find updates + $db = Factory::getDbo(); + $db->setQuery( + $db->getQuery(true) + ->delete($db->quoteName('#__updates')) + ); + $db->execute(); + + // Trigger update finder + \Joomla\CMS\Updater\Updater::getInstance()->findUpdates(); + + // Count results + $db->setQuery( + $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__updates')) + ->where($db->quoteName('extension_id') . ' != 0') + ); + $count = (int) $db->loadResult(); + + $this->sendHealthResponse(200, [ + 'status' => 'ok', + 'updates_found' => $count, + 'message' => $count . ' update(s) available', + ]); + } + catch (\Exception $e) + { + $this->sendHealthResponse(500, [ + 'error' => 'Update check failed', + 'message' => $e->getMessage(), + ]); + } + } + + /** + * Clear Joomla cache. + * + * @return void + * @since 02.01.39 + */ + protected function handleCacheAction() + { + if ($this->app->input->getMethod() !== 'POST') + { + $this->sendHealthResponse(405, ['error' => 'POST required']); + + return; + } + + try + { + $cache = Factory::getCache(''); + $cache->clean(''); + + // Also clean admin cache + $adminCache = Factory::getCache('', 'callback', 'administrator'); + $adminCache->clean(''); + + // Clear opcache if available + if (function_exists('opcache_reset')) + { + opcache_reset(); + } + + $this->sendHealthResponse(200, [ + 'status' => 'ok', + 'message' => 'Cache cleared', + ]); + } + catch (\Exception $e) + { + $this->sendHealthResponse(500, [ + 'error' => 'Cache clear failed', + 'message' => $e->getMessage(), + ]); + } + } + + /** + * Trigger Akeeba Backup via frontend API. + * + * @return void + * @since 02.01.39 + */ + protected function handleBackupAction() + { + if ($this->app->input->getMethod() !== 'POST') + { + $this->sendHealthResponse(405, ['error' => 'POST required']); + + return; + } + + try + { + $db = Factory::getDbo(); + $tables = $db->getTableList(); + $prefix = $db->getPrefix(); + + if (!in_array($prefix . 'ak_stats', $tables)) + { + $this->sendHealthResponse(404, [ + 'error' => 'Akeeba Backup not installed', + ]); + + return; + } + + // Get profile from request (default 1) + $body = json_decode(file_get_contents('php://input'), true); + $profile = (int) ($body['profile'] ?? 1); + + // Start backup via Akeeba's internal API + if (class_exists('\Akeeba\Engine\Platform')) + { + \Akeeba\Engine\Platform::getInstance()->load_configuration($profile); + $engine = \Akeeba\Engine\Factory::getEngineInstance(); + + $result = $engine->start($profile); + + $this->sendHealthResponse(200, [ + 'status' => 'started', + 'profile' => $profile, + 'message' => 'Backup started', + ]); + } + else + { + // Fallback: trigger via URL if frontend backup is enabled + $this->sendHealthResponse(501, [ + 'error' => 'Akeeba Engine not loadable', + 'message' => 'Use the Akeeba frontend URL or admin panel instead', + ]); + } + } + catch (\Exception $e) + { + $this->sendHealthResponse(500, [ + 'error' => 'Backup failed', + 'message' => $e->getMessage(), + ]); + } + } + + /** + * Return a compact site info summary. + * + * @return void + * @since 02.01.39 + */ + protected function handleInfoAction() + { + $config = Factory::getConfig(); + $db = Factory::getDbo(); + + $db->setQuery($db->getQuery(true)->select('COUNT(*)')->from($db->quoteName('#__content'))); + $articles = (int) $db->loadResult(); + + $db->setQuery($db->getQuery(true)->select('COUNT(*)')->from($db->quoteName('#__users'))); + $users = (int) $db->loadResult(); + + $db->setQuery($db->getQuery(true)->select('COUNT(*)')->from($db->quoteName('#__extensions'))->where($db->quoteName('enabled') . ' = 1')); + $extensions = (int) $db->loadResult(); + + $this->sendHealthResponse(200, [ + 'site_name' => $config->get('sitename', ''), + 'site_url' => rtrim(Uri::root(), '/'), + 'joomla_version' => JVERSION, + 'php_version' => PHP_VERSION, + 'db_type' => $db->getName(), + 'debug' => (bool) $config->get('debug', 0), + 'sef' => (bool) $config->get('sef', 0), + 'caching' => (bool) $config->get('caching', 0), + 'articles' => $articles, + 'users' => $users, + 'extensions' => $extensions, + 'brand' => $this->params->get('brand_name', 'MokoWaaS'), + 'plugin_version' => '02.01.39', + ]); + } + + /** + * Health check action — delegates to existing health check logic. + * + * @return void + * @since 02.01.39 + */ + protected function handleHealthAction() + { + // Token already validated by handleMokoApi() // Collect diagnostics $checks = $this->collectHealthChecks(); - // Determine overall status from individual checks + // Determine overall status and collect reasons $overall = 'ok'; + $reasons = []; - foreach ($checks as $check) + foreach ($checks as $name => $check) { - if (($check['status'] ?? 'ok') === 'error') + $checkStatus = $check['status'] ?? 'ok'; + + if ($checkStatus === 'error') { $overall = 'error'; - break; + $reasons[] = $name . ': ' . ($check['message'] ?? 'error'); } - - if (($check['status'] ?? 'ok') === 'degraded') + elseif ($checkStatus === 'degraded') { - $overall = 'degraded'; + if ($overall !== 'error') + { + $overall = 'degraded'; + } + + // Build human-readable reason + if ($name === 'extensions' + && isset($check['pending_updates'])) + { + $reasons[] = $check['pending_updates'] + . ' extension update' + . ($check['pending_updates'] > 1 ? 's' : '') + . ' available'; + } + elseif ($name === 'filesystem' + && isset($check['free_disk_mb']) + && $check['free_disk_mb'] < 100) + { + $reasons[] = 'Low disk space: ' + . $check['free_disk_mb'] . ' MB free'; + } + elseif ($name === 'backup') + { + if (!empty($check['message'])) + { + $reasons[] = $check['message']; + } + elseif (isset($check['days_since']) + && $check['days_since'] > 7) + { + $reasons[] = 'Last backup ' + . $check['days_since'] . ' days ago'; + } + elseif (isset($check['last_status']) + && $check['last_status'] !== 'complete') + { + $reasons[] = 'Last backup status: ' + . $check['last_status']; + } + else + { + $reasons[] = 'Backup: degraded'; + } + } + elseif ($name === 'ssl' && isset($check['days_left'])) + { + $reasons[] = 'SSL expires in ' + . $check['days_left'] . ' days'; + } + elseif ($name === 'cron' && isset($check['failed_24h'])) + { + $reasons[] = $check['failed_24h'] + . ' scheduled task(s) failed'; + } + elseif ($name === 'config' && !empty($check['issues'])) + { + $reasons[] = implode(', ', $check['issues']); + } + else + { + $reasons[] = $name . ': degraded'; + } } } $payload = [ 'status' => $overall, + 'reason' => implode('; ', $reasons) ?: null, 'timestamp' => gmdate('Y-m-d\TH:i:s\Z'), 'checks' => $checks, 'meta' => $this->collectHealthMeta(), @@ -1129,12 +1411,26 @@ class MokoWaaS extends CMSPlugin */ protected function collectHealthChecks() { - return [ + $checks = [ 'database' => $this->checkDatabase(), 'filesystem' => $this->checkFilesystem(), 'cache' => $this->checkCache(), 'extensions' => $this->checkExtensions(), + 'backup' => $this->checkAkeebaBackup(), + 'security' => $this->checkAdminTools(), + 'ssl' => $this->checkSsl(), + 'cron' => $this->checkScheduledTasks(), + 'errors' => $this->checkErrorLog(), + 'db_size' => $this->checkDatabaseSize(), + 'content' => $this->checkContent(), + 'users' => $this->checkUserActivity(), + 'mail' => $this->checkMail(), + 'seo' => $this->checkSeo(), + 'template' => $this->checkTemplate(), + 'config' => $this->checkConfigDrift(), ]; + + return $checks; } /** @@ -1232,12 +1528,55 @@ class MokoWaaS extends CMSPlugin $status = 'degraded'; } + // Total disk and site size + $totalBytes = @disk_total_space(JPATH_ROOT); + $totalMb = $totalBytes !== false + ? round($totalBytes / 1048576) + : null; + + // Site directory size (quick estimate via common dirs) + $siteMb = null; + + try + { + $siteSize = 0; + + foreach (['images', 'media', 'tmp', 'cache', + 'administrator/logs', 'administrator/cache'] as $dir) + { + $path = JPATH_ROOT . '/' . $dir; + + if (is_dir($path)) + { + $iter = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator( + $path, + \FilesystemIterator::SKIP_DOTS + ) + ); + + foreach ($iter as $file) + { + $siteSize += $file->getSize(); + } + } + } + + $siteMb = round($siteSize / 1048576); + } + catch (\Exception $e) + { + // Ignore — siteMb stays null + } + return [ 'status' => $status, 'tmp_writable' => $tmpWritable, 'log_writable' => $logWritable, 'cache_writable' => $cacheWritable, 'free_disk_mb' => $freeMb, + 'total_disk_mb' => $totalMb, + 'site_size_mb' => $siteMb, ]; } @@ -1320,6 +1659,774 @@ class MokoWaaS extends CMSPlugin } } + /** + * Check Akeeba Backup status — last backup date, status, and profile. + * + * Queries the #__ak_stats table (Akeeba Backup) for the most recent + * backup record. Returns 'not_installed' if the table doesn't exist. + * + * @return array Check result with backup info + * + * @since 02.01.39 + */ + protected function checkAkeebaBackup() + { + try + { + $db = Factory::getDbo(); + + // Check if Akeeba Backup is installed + $tables = $db->getTableList(); + $prefix = $db->getPrefix(); + $akTable = $prefix . 'ak_stats'; + + if (!in_array($akTable, $tables)) + { + return [ + 'status' => 'ok', + 'installed' => false, + ]; + } + + // Get the most recent backup + $query = $db->getQuery(true) + ->select([ + $db->quoteName('id'), + $db->quoteName('description'), + $db->quoteName('status'), + $db->quoteName('backupstart'), + $db->quoteName('backupend'), + $db->quoteName('profile_id'), + $db->quoteName('total_size'), + ]) + ->from($db->quoteName('#__ak_stats')) + ->order($db->quoteName('id') . ' DESC'); + + $db->setQuery($query, 0, 1); + $latest = $db->loadObject(); + + if (!$latest) + { + return [ + 'status' => 'degraded', + 'installed' => true, + 'message' => 'No backups found', + ]; + } + + // Count total backups and recent (last 7 days) + $db->setQuery( + $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__ak_stats')) + ); + $totalBackups = (int) $db->loadResult(); + + $db->setQuery( + $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__ak_stats')) + ->where($db->quoteName('backupstart') + . ' >= DATE_SUB(NOW(), INTERVAL 7 DAY)') + ); + $recentBackups = (int) $db->loadResult(); + + // Check if last backup is older than 7 days + $lastDate = $latest->backupstart; + $daysSince = (int) ((time() - strtotime($lastDate)) / 86400); + $backupSize = $latest->total_size + ? round($latest->total_size / 1048576) + : null; + + $status = 'ok'; + + if ($latest->status !== 'complete') + { + $status = 'degraded'; + } + elseif ($daysSince > 7) + { + $status = 'degraded'; + } + + return [ + 'status' => $status, + 'installed' => true, + 'last_backup' => $lastDate, + 'last_status' => $latest->status, + 'last_size_mb' => $backupSize, + 'days_since' => $daysSince, + 'profile_id' => (int) $latest->profile_id, + 'total_backups' => $totalBackups, + 'recent_7d' => $recentBackups, + 'description' => $latest->description, + ]; + } + catch (\Exception $e) + { + return [ + 'status' => 'ok', + 'installed' => false, + ]; + } + } + + /** + * Check Admin Tools status — WAF status, security exceptions. + * + * Queries Admin Tools tables for firewall status and recent blocks. + * Returns 'not_installed' if tables don't exist. + * + * @return array Check result with security info + * + * @since 02.01.39 + */ + protected function checkAdminTools() + { + try + { + $db = Factory::getDbo(); + $tables = $db->getTableList(); + $prefix = $db->getPrefix(); + + // Check if Admin Tools is installed + $atTable = $prefix . 'admintools_log'; + + if (!in_array($atTable, $tables)) + { + return [ + 'status' => 'ok', + 'installed' => false, + ]; + } + + // Count blocked requests in last 24h + $db->setQuery( + $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__admintools_log')) + ->where($db->quoteName('logdate') + . ' >= DATE_SUB(NOW(), INTERVAL 1 DAY)') + ); + $blocked24h = (int) $db->loadResult(); + + // Count blocked in last 7 days + $db->setQuery( + $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__admintools_log')) + ->where($db->quoteName('logdate') + . ' >= DATE_SUB(NOW(), INTERVAL 7 DAY)') + ); + $blocked7d = (int) $db->loadResult(); + + // Check WAF config if available + $wafEnabled = null; + $wafTable = $prefix . 'admintools_wafconfig'; + + if (in_array($wafTable, $tables)) + { + $db->setQuery( + $db->getQuery(true) + ->select($db->quoteName('value')) + ->from($db->quoteName('#__admintools_wafconfig')) + ->where($db->quoteName('key') . ' = ' + . $db->quote('ipworkarounds')) + ); + $wafEnabled = $db->loadResult() !== null; + } + + return [ + 'status' => 'ok', + 'installed' => true, + 'blocked_24h' => $blocked24h, + 'blocked_7d' => $blocked7d, + 'waf_active' => $wafEnabled, + ]; + } + catch (\Exception $e) + { + return [ + 'status' => 'ok', + 'installed' => false, + ]; + } + } + + /** + * Check SSL certificate expiry. + * + * @return array + * @since 02.01.39 + */ + protected function checkSsl() + { + try + { + $siteUrl = Uri::root(); + $host = parse_url($siteUrl, PHP_URL_HOST); + + if (empty($host) || parse_url($siteUrl, PHP_URL_SCHEME) !== 'https') + { + return ['status' => 'ok', 'https' => false]; + } + + $ctx = stream_context_create([ + 'ssl' => ['capture_peer_cert' => true, 'verify_peer' => false], + ]); + $stream = @stream_socket_client( + "ssl://{$host}:443", $errno, $errstr, 10, + STREAM_CLIENT_CONNECT, $ctx + ); + + if (!$stream) + { + return ['status' => 'degraded', 'https' => true, 'message' => 'Cannot connect']; + } + + $params = stream_context_get_params($stream); + $cert = openssl_x509_parse($params['options']['ssl']['peer_certificate']); + fclose($stream); + + $expiresTs = $cert['validTo_time_t'] ?? 0; + $daysLeft = (int) (($expiresTs - time()) / 86400); + $issuer = $cert['issuer']['O'] ?? $cert['issuer']['CN'] ?? 'Unknown'; + $status = $daysLeft < 7 ? 'error' : ($daysLeft < 30 ? 'degraded' : 'ok'); + + return [ + 'status' => $status, + 'https' => true, + 'expires' => gmdate('Y-m-d', $expiresTs), + 'days_left' => $daysLeft, + 'issuer' => $issuer, + ]; + } + catch (\Exception $e) + { + return ['status' => 'ok', 'https' => false]; + } + } + + /** + * Check Joomla scheduled tasks (Joomla 4.1+). + * + * @return array + * @since 02.01.39 + */ + protected function checkScheduledTasks() + { + try + { + $db = Factory::getDbo(); + $tables = $db->getTableList(); + $prefix = $db->getPrefix(); + + if (!in_array($prefix . 'scheduler_tasks', $tables)) + { + return ['status' => 'ok', 'available' => false]; + } + + $db->setQuery( + $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__scheduler_tasks')) + ->where($db->quoteName('state') . ' = 1') + ); + $enabled = (int) $db->loadResult(); + + $db->setQuery( + $db->getQuery(true) + ->select([ + $db->quoteName('title'), + $db->quoteName('last_execution'), + $db->quoteName('last_exit_code'), + $db->quoteName('next_execution'), + ]) + ->from($db->quoteName('#__scheduler_tasks')) + ->where($db->quoteName('state') . ' = 1') + ->order($db->quoteName('last_execution') . ' DESC') + ); + $db->setQuery($db->getQuery(true), 0, 5); + // Re-run the query + $db->setQuery( + $db->getQuery(true) + ->select([ + $db->quoteName('title'), + $db->quoteName('last_execution'), + $db->quoteName('last_exit_code'), + $db->quoteName('next_execution'), + ]) + ->from($db->quoteName('#__scheduler_tasks')) + ->where($db->quoteName('state') . ' = 1') + ->order($db->quoteName('last_execution') . ' DESC'), + 0, 1 + ); + $last = $db->loadObject(); + + // Count failed in last 24h + $db->setQuery( + $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__scheduler_tasks')) + ->where($db->quoteName('last_exit_code') . ' != 0') + ->where($db->quoteName('last_execution') + . ' >= DATE_SUB(NOW(), INTERVAL 1 DAY)') + ); + $failed24h = (int) $db->loadResult(); + + $status = $failed24h > 0 ? 'degraded' : 'ok'; + + return [ + 'status' => $status, + 'available' => true, + 'enabled_tasks' => $enabled, + 'failed_24h' => $failed24h, + 'last_run' => $last->last_execution ?? null, + 'last_exit_code' => $last ? (int) $last->last_exit_code : null, + 'last_task' => $last->title ?? null, + ]; + } + catch (\Exception $e) + { + return ['status' => 'ok', 'available' => false]; + } + } + + /** + * Check PHP error log for recent errors. + * + * @return array + * @since 02.01.39 + */ + protected function checkErrorLog() + { + $logFile = JPATH_ROOT . '/administrator/logs/error.php'; + $altLog = ini_get('error_log'); + + $file = null; + + if (file_exists($logFile) && is_readable($logFile)) + { + $file = $logFile; + } + elseif ($altLog && file_exists($altLog) && is_readable($altLog)) + { + $file = $altLog; + } + + if (!$file) + { + return [ + 'status' => 'ok', + 'log_available' => false, + ]; + } + + $size = filesize($file); + $sizeMb = round($size / 1048576, 1); + + // Count recent lines (tail last 50 lines, count errors) + $lines = file_exists($file) ? @file($file) : []; + $recent = array_slice($lines, -50); + $errors24h = 0; + $lastError = null; + $yesterday = date('Y-m-d', strtotime('-1 day')); + + foreach ($recent as $line) + { + if (stripos($line, 'error') !== false + || stripos($line, 'fatal') !== false) + { + $errors24h++; + $lastError = trim(substr($line, 0, 200)); + } + } + + return [ + 'status' => 'ok', + 'log_available' => true, + 'log_size_mb' => $sizeMb, + 'recent_errors' => $errors24h, + 'last_error' => $lastError, + ]; + } + + /** + * Check database size and largest tables. + * + * @return array + * @since 02.01.39 + */ + protected function checkDatabaseSize() + { + try + { + $db = Factory::getDbo(); + $config = Factory::getConfig(); + $dbName = $config->get('db'); + + $db->setQuery( + "SELECT ROUND(SUM(data_length + index_length) / 1048576, 1) AS size_mb " + . "FROM information_schema.tables WHERE table_schema = " + . $db->quote($dbName) + ); + $totalMb = (float) $db->loadResult(); + + // Largest tables + $db->setQuery( + "SELECT table_name, " + . "ROUND((data_length + index_length) / 1048576, 1) AS size_mb " + . "FROM information_schema.tables " + . "WHERE table_schema = " . $db->quote($dbName) + . " ORDER BY (data_length + index_length) DESC LIMIT 5" + ); + $largest = []; + + foreach ($db->loadObjectList() as $t) + { + $largest[$t->table_name] = (float) $t->size_mb; + } + + // Table count + $db->setQuery( + "SELECT COUNT(*) FROM information_schema.tables " + . "WHERE table_schema = " . $db->quote($dbName) + ); + $tableCount = (int) $db->loadResult(); + + return [ + 'status' => 'ok', + 'total_mb' => $totalMb, + 'table_count' => $tableCount, + 'largest' => $largest, + ]; + } + catch (\Exception $e) + { + return ['status' => 'ok', 'total_mb' => null]; + } + } + + /** + * Check content statistics. + * + * @return array + * @since 02.01.39 + */ + protected function checkContent() + { + try + { + $db = Factory::getDbo(); + + $counts = []; + + foreach ([ + 'articles' => '#__content', + 'categories' => '#__categories', + 'menu_items' => '#__menu', + 'modules' => '#__modules', + 'media' => '#__media_files', + ] as $label => $table) + { + try + { + $db->setQuery( + $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName($table)) + ); + $counts[$label] = (int) $db->loadResult(); + } + catch (\Exception $e) + { + // Table might not exist + } + } + + return [ + 'status' => 'ok', + 'counts' => $counts, + ]; + } + catch (\Exception $e) + { + return ['status' => 'ok', 'counts' => []]; + } + } + + /** + * Check user activity — last login, active sessions, failed logins. + * + * @return array + * @since 02.01.39 + */ + protected function checkUserActivity() + { + try + { + $db = Factory::getDbo(); + + // Total users + $db->setQuery( + $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__users')) + ); + $totalUsers = (int) $db->loadResult(); + + // Last login + $db->setQuery( + $db->getQuery(true) + ->select($db->quoteName('lastvisitDate')) + ->from($db->quoteName('#__users')) + ->where($db->quoteName('lastvisitDate') + . ' IS NOT NULL') + ->order($db->quoteName('lastvisitDate') . ' DESC'), + 0, 1 + ); + $lastLogin = $db->loadResult(); + + // Active sessions + $db->setQuery( + $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__session')) + ->where($db->quoteName('guest') . ' = 0') + ); + $activeSessions = (int) $db->loadResult(); + + // Failed logins (from action logs if available) + $failedLogins = 0; + + try + { + $db->setQuery( + $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__action_logs')) + ->where($db->quoteName('message_language_key') + . ' LIKE ' . $db->quote('%LOGIN_FAILED%')) + ->where($db->quoteName('log_date') + . ' >= DATE_SUB(NOW(), INTERVAL 1 DAY)') + ); + $failedLogins = (int) $db->loadResult(); + } + catch (\Exception $e) + { + // Action logs might not track this + } + + return [ + 'status' => 'ok', + 'total_users' => $totalUsers, + 'last_login' => $lastLogin, + 'active_sessions' => $activeSessions, + 'failed_24h' => $failedLogins, + ]; + } + catch (\Exception $e) + { + return ['status' => 'ok', 'total_users' => null]; + } + } + + /** + * Check mail system status. + * + * @return array + * @since 02.01.39 + */ + protected function checkMail() + { + try + { + $config = Factory::getConfig(); + $mailer = $config->get('mailer', 'mail'); + $from = $config->get('mailfrom', ''); + $smtpHost = $config->get('smtphost', ''); + + // Check mail queue if available + $db = Factory::getDbo(); + $tables = $db->getTableList(); + $prefix = $db->getPrefix(); + + $queueCount = 0; + + if (in_array($prefix . 'mail_queue', $tables)) + { + $db->setQuery( + $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__mail_queue')) + ); + $queueCount = (int) $db->loadResult(); + } + + return [ + 'status' => 'ok', + 'mailer' => $mailer, + 'from' => $from, + 'smtp_host' => $mailer === 'smtp' ? $smtpHost : null, + 'queue' => $queueCount, + ]; + } + catch (\Exception $e) + { + return ['status' => 'ok', 'mailer' => null]; + } + } + + /** + * Check basic SEO health indicators. + * + * @return array + * @since 02.01.39 + */ + protected function checkSeo() + { + $robotsTxt = file_exists(JPATH_ROOT . '/robots.txt'); + $htaccess = file_exists(JPATH_ROOT . '/.htaccess'); + + // Check for sitemap + $sitemapXml = file_exists(JPATH_ROOT . '/sitemap.xml'); + $sitemapIdx = file_exists(JPATH_ROOT . '/sitemap_index.xml'); + + $config = Factory::getConfig(); + $sef = (bool) $config->get('sef', 0); + + return [ + 'status' => 'ok', + 'robots_txt' => $robotsTxt, + 'htaccess' => $htaccess, + 'sitemap' => $sitemapXml || $sitemapIdx, + 'sef_enabled' => $sef, + ]; + } + + /** + * Check active template info. + * + * @return array + * @since 02.01.39 + */ + protected function checkTemplate() + { + try + { + $db = Factory::getDbo(); + + // Site template + $db->setQuery( + $db->getQuery(true) + ->select($db->quoteName('template')) + ->from($db->quoteName('#__template_styles')) + ->where($db->quoteName('client_id') . ' = 0') + ->where($db->quoteName('home') . ' = 1') + ); + $siteTemplate = $db->loadResult() ?: 'unknown'; + + // Admin template + $db->setQuery( + $db->getQuery(true) + ->select($db->quoteName('template')) + ->from($db->quoteName('#__template_styles')) + ->where($db->quoteName('client_id') . ' = 1') + ->where($db->quoteName('home') . ' = 1') + ); + $adminTemplate = $db->loadResult() ?: 'unknown'; + + // Count template overrides + $overrideCount = 0; + $overridePath = JPATH_ROOT . '/templates/' . $siteTemplate . '/html'; + + if (is_dir($overridePath)) + { + $iter = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator( + $overridePath, + \FilesystemIterator::SKIP_DOTS + ) + ); + + foreach ($iter as $file) + { + if ($file->isFile()) + { + $overrideCount++; + } + } + } + + return [ + 'status' => 'ok', + 'site_template' => $siteTemplate, + 'admin_template' => $adminTemplate, + 'override_count' => $overrideCount, + ]; + } + catch (\Exception $e) + { + return ['status' => 'ok', 'site_template' => null]; + } + } + + /** + * Check configuration for common misconfigurations. + * + * @return array + * @since 02.01.39 + */ + protected function checkConfigDrift() + { + $config = Factory::getConfig(); + + $debug = (bool) $config->get('debug', 0); + $errorReport = $config->get('error_reporting', 'default'); + $gzip = (bool) $config->get('gzip', 0); + $sef = (bool) $config->get('sef', 0); + $sefRewrite = (bool) $config->get('sef_rewrite', 0); + $forceSSL = (int) $config->get('force_ssl', 0); + $caching = (bool) $config->get('caching', 0); + $lifetime = (int) $config->get('lifetime', 15); + $tmpPath = $config->get('tmp_path', ''); + $logPath = $config->get('log_path', ''); + + // Flag potential issues + $issues = []; + + if ($debug) + { + $issues[] = 'Debug mode is ON'; + } + + if ($errorReport === 'maximum' + || $errorReport === 'development') + { + $issues[] = 'Error reporting: ' . $errorReport; + } + + if ($forceSSL === 0) + { + $issues[] = 'Force SSL is OFF'; + } + + $status = empty($issues) ? 'ok' : 'degraded'; + + return [ + 'status' => $status, + 'debug' => $debug, + 'error_report' => $errorReport, + 'gzip' => $gzip, + 'sef' => $sef, + 'sef_rewrite' => $sefRewrite, + 'force_ssl' => $forceSSL, + 'caching' => $caching, + 'lifetime' => $lifetime, + 'issues' => $issues ?: null, + ]; + } + /** * Send a JSON health response and terminate execution. * @@ -1342,638 +2449,222 @@ class MokoWaaS extends CMSPlugin } // ------------------------------------------------------------------ - // Grafana Provisioning (called from onExtensionAfterSave) + // Remote Install Endpoint (called from onAfterInitialise) // ------------------------------------------------------------------ /** - * Handle Grafana datasource and dashboard provisioning. + * Handle remote extension install requests. * - * When the health endpoint is enabled and Grafana credentials are - * configured, auto-provisions an Infinity datasource and a MokoWaaS - * dashboard in the target Grafana instance. Removes them when disabled. + * POST /?mokowaas=install with a ZIP URL in the request body. + * Requires the same health API token + HTTPS. Downloads the ZIP + * and installs via Joomla's InstallerModel. + * + * Request: POST /?mokowaas=install + * Headers: Authorization: Bearer + * Body: {"url": "https://example.com/extension.zip"} + * + * @return void + * + * @since 02.01.39 + */ + protected function handleInstallAction() + { + if ($this->app->input->getMethod() !== 'POST') + { + $this->sendHealthResponse(405, ['error' => 'POST required']); + + return; + } + + // Parse request body + $body = json_decode(file_get_contents('php://input'), true); + $url = $body['url'] ?? ''; + + if (empty($url)) + { + $this->sendHealthResponse(400, ['error' => 'url required']); + + return; + } + + // Validate URL is HTTPS + if (stripos($url, 'https://') !== 0) + { + $this->sendHealthResponse(400, ['error' => 'HTTPS URL required']); + + return; + } + + try + { + // Download the ZIP + $tmpFile = $this->app->getConfig()->get('tmp_path', JPATH_ROOT . '/tmp') + . '/mokowaas_install_' . md5($url) . '.zip'; + + $ch = curl_init($url); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); + curl_setopt($ch, CURLOPT_TIMEOUT, 120); + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); + $zipData = curl_exec($ch); + $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + $error = curl_error($ch); + curl_close($ch); + + if ($error || $code !== 200 || empty($zipData)) + { + $this->sendHealthResponse(502, [ + 'error' => 'Download failed', + 'http' => $code, + 'message' => $error ?: 'Empty response', + ]); + + return; + } + + file_put_contents($tmpFile, $zipData); + + // Install using Joomla's installer + $installer = \Joomla\CMS\Installer\Installer::getInstance(); + $result = $installer->install($tmpFile); + + @unlink($tmpFile); + + if ($result) + { + $this->sendHealthResponse(200, [ + 'status' => 'installed', + 'message' => 'Extension installed successfully', + 'url' => $url, + ]); + } + else + { + $this->sendHealthResponse(500, [ + 'error' => 'Installation failed', + 'message' => 'Joomla installer returned false', + 'url' => $url, + ]); + } + } + catch (\Exception $e) + { + @unlink($tmpFile ?? ''); + + $this->sendHealthResponse(500, [ + 'error' => 'Install exception', + 'message' => $e->getMessage(), + 'url' => $url, + ]); + } + } + + // ------------------------------------------------------------------ + // Heartbeat (called from onExtensionAfterSave) + // ------------------------------------------------------------------ + + /** + * Send heartbeat to the MokoWaaS monitoring receiver. + * + * Registers this site (and any aliases) with the Grafana provisioning system. + * The receiver writes a datasource YAML file and restarts Grafana. * * @param \Joomla\Registry\Registry $params Plugin params * @param \Joomla\CMS\Application\CMSApplication $app Application * * @return void * - * @since 02.01.22 + * @since 02.01.36 */ protected function handleGrafanaProvisioning($params, $app) { - $grafanaUrl = rtrim(self::deobfuscate(self::G_URL), '/'); - $grafanaKey = self::deobfuscate(self::G_KEY); $healthToken = $params->get('health_api_token', ''); - $siteUrl = rtrim(Uri::root(), '/'); - $siteName = Factory::getConfig()->get('sitename', 'Joomla'); - $dsUid = 'mokowaas-' . md5($siteUrl); if (empty($healthToken)) { return; } - // Ensure Infinity datasource plugin is installed - $pluginOk = $this->ensureGrafanaPlugin( - $grafanaUrl, $grafanaKey, - 'yesoreyeram-infinity-datasource' - ); + $siteUrl = rtrim(Uri::root(), '/'); + $siteName = Factory::getConfig()->get('sitename', 'Joomla'); - if ($pluginOk !== true) + // Register primary domain + $this->sendHeartbeat($siteUrl, $siteName, $healthToken, $app); + + // Register any alias domains + $aliases = $params->get('site_aliases', ''); + + if (!empty($aliases)) { - $app->enqueueMessage( - 'Grafana plugin install failed: ' . $pluginOk - . ' — install the Infinity plugin manually.', - 'warning' - ); - - return; - } - - $dsResult = $this->provisionGrafanaDatasource( - $grafanaUrl, $grafanaKey, $dsUid, - $siteUrl, $healthToken, $siteName - ); - - if ($dsResult === true) - { - $dbResult = $this->provisionGrafanaDashboard( - $grafanaUrl, $grafanaKey, $dsUid, $siteName - ); - - if ($dbResult === true) + foreach (array_filter(array_map('trim', explode(',', $aliases))) as $alias) { - $app->enqueueMessage( - 'Grafana datasource and dashboard provisioned.', - 'message' - ); + $aliasUrl = 'https://' . ltrim($alias, 'https://'); + $aliasUrl = rtrim($aliasUrl, '/'); + $this->sendHeartbeat($aliasUrl, $siteName . ' (' . $alias . ')', $healthToken, $app); } - else - { - $app->enqueueMessage( - 'Grafana datasource created but dashboard failed: ' - . $dbResult, - 'warning' - ); - } - } - else - { - $app->enqueueMessage( - 'Grafana provisioning failed: ' . $dsResult, - 'warning' - ); } } /** - * Create or update an Infinity datasource in Grafana. + * Send a single heartbeat registration to the receiver. * - * @param string $grafanaUrl Grafana base URL - * @param string $grafanaKey Grafana API key - * @param string $dsUid Datasource UID - * @param string $siteUrl Site base URL + * @param string $siteUrl Site URL to register + * @param string $siteName Display name for Grafana * @param string $healthToken Health API bearer token - * @param string $siteName Joomla site name - * - * @return true|string True on success, error message on failure - * - * @since 02.01.22 - */ - protected function provisionGrafanaDatasource( - $grafanaUrl, $grafanaKey, $dsUid, - $siteUrl, $healthToken, $siteName - ) - { - $dsPayload = [ - 'uid' => $dsUid, - 'name' => 'MokoWaaS — ' . $siteName, - 'type' => 'yesoreyeram-infinity-datasource', - 'access' => 'proxy', - 'url' => $siteUrl, - 'jsonData' => [ - 'auth_method' => 'bearerToken', - 'global_queries' => [], - ], - 'secureJsonData' => [ - 'bearerToken' => $healthToken, - ], - ]; - - // Try update first (PUT), fall back to create (POST) - $response = $this->grafanaRequest( - $grafanaUrl, $grafanaKey, - 'PUT', '/api/datasources/uid/' . $dsUid, - $dsPayload - ); - - if ($response['code'] === 200) - { - return true; - } - - if ($response['code'] === 404) - { - // Datasource doesn't exist — create it - $response = $this->grafanaRequest( - $grafanaUrl, $grafanaKey, - 'POST', '/api/datasources', - $dsPayload - ); - - if ($response['code'] === 200 || $response['code'] === 409) - { - return true; - } - } - - return 'HTTP ' . $response['code'] . ': ' - . ($response['body']['message'] ?? 'Unknown error'); - } - - /** - * Create or update the MokoWaaS dashboard in Grafana. - * - * @param string $grafanaUrl Grafana base URL - * @param string $grafanaKey Grafana API key - * @param string $dsUid Datasource UID - * @param string $siteName Joomla site name - * - * @return true|string True on success, error message on failure - * - * @since 02.01.22 - */ - protected function provisionGrafanaDashboard( - $grafanaUrl, $grafanaKey, $dsUid, $siteName - ) - { - $dashboard = $this->buildDashboardModel($dsUid, $siteName); - - $payload = [ - 'dashboard' => $dashboard, - 'folderUid' => 'cfldz4r88nhfkc', - 'overwrite' => true, - 'message' => 'Auto-provisioned by MokoWaaS plugin', - ]; - - $response = $this->grafanaRequest( - $grafanaUrl, $grafanaKey, - 'POST', '/api/dashboards/db', - $payload - ); - - if ($response['code'] === 200) - { - return true; - } - - return 'HTTP ' . $response['code'] . ': ' - . ($response['body']['message'] ?? 'Unknown error'); - } - - /** - * Remove MokoWaaS datasource and dashboard from Grafana. - * - * @param string $grafanaUrl Grafana base URL - * @param string $grafanaKey Grafana API key - * @param string $dsUid Datasource UID + * @param object $app Application for messages * * @return void * - * @since 02.01.22 + * @since 02.01.39 */ - protected function deprovisionGrafana( - $grafanaUrl, $grafanaKey, $dsUid - ) + protected function sendHeartbeat($siteUrl, $siteName, $healthToken, $app) { - // Only remove this site's datasource — the shared dashboard - // remains for other endpoints - $this->grafanaRequest( - $grafanaUrl, $grafanaKey, - 'DELETE', '/api/datasources/uid/' . $dsUid - ); - } + $payload = json_encode([ + 'site_url' => $siteUrl, + 'site_name' => $siteName, + 'health_token' => $healthToken, + 'action' => 'register', + ], JSON_UNESCAPED_SLASHES); - /** - * Ensure a Grafana plugin is installed, installing via API if needed. - * - * Checks the Grafana Plugin API for the given plugin ID. If not - * found, attempts installation via POST /api/plugins//install - * (Grafana 10+). This replaces the deprecated grafana-cli approach. - * - * @param string $grafanaUrl Grafana base URL - * @param string $grafanaKey Grafana API key - * @param string $pluginId Plugin ID (e.g. yesoreyeram-infinity-datasource) - * - * @return true|string True if installed, error message otherwise - * - * @since 02.01.22 - */ - protected function ensureGrafanaPlugin( - $grafanaUrl, $grafanaKey, $pluginId - ) - { - // Check if plugin is already installed - $response = $this->grafanaRequest( - $grafanaUrl, $grafanaKey, - 'GET', '/api/plugins/' . $pluginId . '/settings' - ); - - if ($response['code'] === 200) - { - return true; - } - - // Plugin not installed — install via API - $response = $this->grafanaRequest( - $grafanaUrl, $grafanaKey, - 'POST', '/api/plugins/' . $pluginId . '/install', - new \stdClass() - ); - - if ($response['code'] === 200) - { - Log::add( - 'Grafana plugin ' . $pluginId . ' installed via API', - Log::INFO, - 'mokowaas' - ); - - return true; - } - - return 'HTTP ' . $response['code'] . ': ' - . ($response['body']['message'] ?? 'Unknown error'); - } - - /** - * Make an HTTP request to the Grafana API. - * - * Uses cURL to communicate with the Grafana instance. All inputs - * are passed as structured data (JSON body, HTTP headers) — no - * shell commands are invoked. - * - * @param string $grafanaUrl Grafana base URL - * @param string $grafanaKey Grafana API key - * @param string $method HTTP method - * @param string $path API path - * @param array|null $data Request body (JSON-encoded) - * - * @return array ['code' => int, 'body' => array] - * - * @since 02.01.22 - */ - protected function grafanaRequest( - $grafanaUrl, $grafanaKey, $method, $path, $data = null - ) - { - $url = $grafanaUrl . $path; - - $headers = [ - 'Authorization: Bearer ' . $grafanaKey, + $ch = curl_init(self::HEARTBEAT_URL . '/register'); + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_HTTPHEADER, [ 'Content-Type: application/json', - 'Accept: application/json', - ]; - - $ch = curl_init($url); - curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method); - curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); + 'X-MokoWaaS-Key: ' . self::HEARTBEAT_KEY, + ]); + curl_setopt($ch, CURLOPT_POSTFIELDS, $payload); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_TIMEOUT, 15); curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); - if ($data !== null) - { - curl_setopt( - $ch, CURLOPT_POSTFIELDS, - json_encode($data, JSON_UNESCAPED_SLASHES) - ); - } - - $responseBody = curl_exec($ch); - $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); - $curlError = curl_error($ch); + $response = curl_exec($ch); + $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + $error = curl_error($ch); curl_close($ch); - if ($curlError) - { - Log::add( - 'Grafana API error: ' . $curlError, - Log::WARNING, - 'mokowaas' - ); + $body = json_decode($response, true); - return [ - 'code' => 0, - 'body' => ['message' => $curlError], - ]; + if ($error) + { + $app->enqueueMessage('Grafana heartbeat failed (' . $siteUrl . '): ' . $error, 'warning'); + Log::add('Heartbeat failed: ' . $error, Log::WARNING, 'mokowaas'); } - - return [ - 'code' => $httpCode, - 'body' => json_decode($responseBody, true) ?: [], - ]; - } - - /** - * Build the Grafana dashboard JSON model. - * - * Creates a dashboard with panels for all health check metrics: - * overall status, database latency, disk space, extensions, - * Joomla/PHP versions, and cache status. - * - * @param string $dsUid Datasource UID - * @param string $siteName Joomla site name - * - * @return array Grafana dashboard model - * - * @since 02.01.22 - */ - protected function buildDashboardModel($dsUid, $siteName) - { - // Shared dashboard — uses a datasource variable dropdown - // so all WaaS sites share one dashboard. Each site only - // provisions its own Infinity datasource; the dashboard - // is created/updated idempotently. - - $mkQuery = function ($refId, $jsonPath, $type = 'string') + elseif ($code === 200 && ($body['status'] ?? '') === 'registered') { - return [ - 'refId' => $refId, - 'datasource' => [ - 'type' => 'yesoreyeram-infinity-datasource', - 'uid' => '${endpoint}', - ], - 'type' => 'json', - 'source' => 'url', - 'url' => '/?mokowaas=health', - 'format' => 'table', - 'parser' => 'backend', - 'root_selector' => '', - 'columns' => [ - [ - 'selector' => $jsonPath, - 'text' => $refId, - 'type' => $type, - ], - ], - ]; - }; - - $panels = []; - $panelId = 1; - - // --- Row 1: Status overview --- - - $panels[] = [ - 'id' => $panelId++, - 'type' => 'stat', - 'title' => 'Health Status', - 'gridPos' => ['h' => 6, 'w' => 6, 'x' => 0, 'y' => 0], - 'targets' => [$mkQuery('status', 'status')], - 'fieldConfig' => [ - 'defaults' => [ - 'mappings' => [ - ['type' => 'value', 'options' => [ - 'ok' => ['text' => 'HEALTHY', 'color' => 'green'], - 'degraded' => ['text' => 'DEGRADED', 'color' => 'orange'], - 'error' => ['text' => 'ERROR', 'color' => 'red'], - ]], - ], - ], - ], - ]; - - $panels[] = [ - 'id' => $panelId++, - 'type' => 'gauge', - 'title' => 'DB Latency (ms)', - 'gridPos' => ['h' => 6, 'w' => 6, 'x' => 6, 'y' => 0], - 'targets' => [$mkQuery('latency', 'checks.database.latency_ms', 'number')], - 'fieldConfig' => [ - 'defaults' => [ - 'unit' => 'ms', - 'min' => 0, - 'max' => 500, - 'thresholds' => [ - 'mode' => 'absolute', - 'steps' => [ - ['value' => null, 'color' => 'green'], - ['value' => 50, 'color' => 'orange'], - ['value' => 200, 'color' => 'red'], - ], - ], - ], - ], - ]; - - $panels[] = [ - 'id' => $panelId++, - 'type' => 'gauge', - 'title' => 'Free Disk Space', - 'gridPos' => ['h' => 6, 'w' => 6, 'x' => 12, 'y' => 0], - 'targets' => [$mkQuery('disk', 'checks.filesystem.free_disk_mb', 'number')], - 'fieldConfig' => [ - 'defaults' => [ - 'unit' => 'decmbytes', - 'min' => 0, - 'thresholds' => [ - 'mode' => 'absolute', - 'steps' => [ - ['value' => null, 'color' => 'red'], - ['value' => 100, 'color' => 'orange'], - ['value' => 500, 'color' => 'green'], - ], - ], - ], - ], - ]; - - $panels[] = [ - 'id' => $panelId++, - 'type' => 'stat', - 'title' => 'Pending Updates', - 'gridPos' => ['h' => 6, 'w' => 6, 'x' => 18, 'y' => 0], - 'targets' => [$mkQuery('updates', 'checks.extensions.pending_updates', 'number')], - 'fieldConfig' => [ - 'defaults' => [ - 'thresholds' => [ - 'mode' => 'absolute', - 'steps' => [ - ['value' => null, 'color' => 'green'], - ['value' => 1, 'color' => 'orange'], - ['value' => 5, 'color' => 'red'], - ], - ], - ], - ], - ]; - - // --- Row 2: System info --- - - $panels[] = [ - 'id' => $panelId++, - 'type' => 'stat', - 'title' => 'Joomla Version', - 'gridPos' => ['h' => 4, 'w' => 6, 'x' => 0, 'y' => 6], - 'targets' => [$mkQuery('joomla', 'meta.joomla_version')], - ]; - - $panels[] = [ - 'id' => $panelId++, - 'type' => 'stat', - 'title' => 'PHP Version', - 'gridPos' => ['h' => 4, 'w' => 6, 'x' => 6, 'y' => 6], - 'targets' => [$mkQuery('php', 'meta.php_version')], - ]; - - $panels[] = [ - 'id' => $panelId++, - 'type' => 'stat', - 'title' => 'Plugin Version', - 'gridPos' => ['h' => 4, 'w' => 6, 'x' => 12, 'y' => 6], - 'targets' => [$mkQuery('plugin', 'meta.plugin_version')], - ]; - - $panels[] = [ - 'id' => $panelId++, - 'type' => 'stat', - 'title' => 'Cache', - 'gridPos' => ['h' => 4, 'w' => 6, 'x' => 18, 'y' => 6], - 'targets' => [$mkQuery('cache', 'checks.cache.enabled', 'string')], - 'fieldConfig' => [ - 'defaults' => [ - 'mappings' => [ - ['type' => 'value', 'options' => [ - 'true' => ['text' => 'Enabled', 'color' => 'green'], - 'false' => ['text' => 'Disabled', 'color' => 'orange'], - ]], - ], - ], - ], - ]; - - // --- Row 3: Filesystem & DB detail --- - - $panels[] = [ - 'id' => $panelId++, - 'type' => 'stat', - 'title' => '/tmp Writable', - 'gridPos' => ['h' => 4, 'w' => 4, 'x' => 0, 'y' => 10], - 'targets' => [$mkQuery('tmp', 'checks.filesystem.tmp_writable', 'string')], - 'fieldConfig' => [ - 'defaults' => [ - 'mappings' => [ - ['type' => 'value', 'options' => [ - 'true' => ['text' => 'OK', 'color' => 'green'], - 'false' => ['text' => 'FAIL', 'color' => 'red'], - ]], - ], - ], - ], - ]; - - $panels[] = [ - 'id' => $panelId++, - 'type' => 'stat', - 'title' => '/logs Writable', - 'gridPos' => ['h' => 4, 'w' => 4, 'x' => 4, 'y' => 10], - 'targets' => [$mkQuery('log', 'checks.filesystem.log_writable', 'string')], - 'fieldConfig' => [ - 'defaults' => [ - 'mappings' => [ - ['type' => 'value', 'options' => [ - 'true' => ['text' => 'OK', 'color' => 'green'], - 'false' => ['text' => 'FAIL', 'color' => 'red'], - ]], - ], - ], - ], - ]; - - $panels[] = [ - 'id' => $panelId++, - 'type' => 'stat', - 'title' => '/cache Writable', - 'gridPos' => ['h' => 4, 'w' => 4, 'x' => 8, 'y' => 10], - 'targets' => [$mkQuery('cachedir', 'checks.filesystem.cache_writable', 'string')], - 'fieldConfig' => [ - 'defaults' => [ - 'mappings' => [ - ['type' => 'value', 'options' => [ - 'true' => ['text' => 'OK', 'color' => 'green'], - 'false' => ['text' => 'FAIL', 'color' => 'red'], - ]], - ], - ], - ], - ]; - - $panels[] = [ - 'id' => $panelId++, - 'type' => 'stat', - 'title' => 'DB Driver', - 'gridPos' => ['h' => 4, 'w' => 4, 'x' => 12, 'y' => 10], - 'targets' => [$mkQuery('driver', 'checks.database.driver')], - ]; - - $panels[] = [ - 'id' => $panelId++, - 'type' => 'stat', - 'title' => 'Users', - 'gridPos' => ['h' => 4, 'w' => 4, 'x' => 16, 'y' => 10], - 'targets' => [$mkQuery('users', 'checks.database.users', 'number')], - ]; - - $panels[] = [ - 'id' => $panelId++, - 'type' => 'stat', - 'title' => 'DB Status', - 'gridPos' => ['h' => 4, 'w' => 4, 'x' => 20, 'y' => 10], - 'targets' => [$mkQuery('dbstatus', 'checks.database.status')], - 'fieldConfig' => [ - 'defaults' => [ - 'mappings' => [ - ['type' => 'value', 'options' => [ - 'ok' => ['text' => 'OK', 'color' => 'green'], - 'error' => ['text' => 'ERROR', 'color' => 'red'], - ]], - ], - ], - ], - ]; - - return [ - 'uid' => 'mokowaas', - 'title' => 'MokoWaaS', - 'description' => 'MokoWaaS endpoint monitoring — site health,' - . ' database, filesystem, extensions, and system info', - 'tags' => ['mokowaas', 'health', 'joomla', - 'endpoints', 'monitoring'], - 'timezone' => 'browser', - 'schemaVersion' => 39, - 'version' => 0, - 'refresh' => '1m', - 'time' => ['from' => 'now-5m', 'to' => 'now'], - 'templating' => [ - 'list' => [ - [ - 'name' => 'endpoint', - 'label' => 'Endpoint', - 'type' => 'datasource', - 'query' => 'yesoreyeram-infinity-datasource', - 'regex' => '/^MokoWaaS —/', - 'refresh' => 1, - 'current' => new \stdClass(), - ], - ], - ], - 'panels' => $panels, - ]; + $app->enqueueMessage( + 'Grafana heartbeat: ' . $siteUrl . ' registered (' . ($body['ds_uid'] ?? '') . ')', + 'message' + ); + } + else + { + $msg = sprintf('Grafana heartbeat failed (%s): HTTP %d', $siteUrl, $code); + $app->enqueueMessage($msg, 'warning'); + Log::add($msg, Log::WARNING, 'mokowaas'); + } } - // ------------------------------------------------------------------ // HTTPS / Session / License (called from onAfterInitialise) // ------------------------------------------------------------------ diff --git a/src/language/en-GB/plg_system_mokowaas.ini b/src/language/en-GB/plg_system_mokowaas.ini index 5488f557..348613ab 100644 --- a/src/language/en-GB/plg_system_mokowaas.ini +++ b/src/language/en-GB/plg_system_mokowaas.ini @@ -129,3 +129,6 @@ PLG_SYSTEM_MOKOWAAS_UPLOAD_TYPES_LABEL="Allowed Upload Types" PLG_SYSTEM_MOKOWAAS_UPLOAD_TYPES_DESC="Comma-separated list of allowed file extensions for media uploads." PLG_SYSTEM_MOKOWAAS_UPLOAD_SIZE_LABEL="Max Upload Size (MB)" PLG_SYSTEM_MOKOWAAS_UPLOAD_SIZE_DESC="Maximum file upload size in megabytes." + +PLG_SYSTEM_MOKOWAAS_SITE_ALIASES_LABEL="Site Aliases" +PLG_SYSTEM_MOKOWAAS_SITE_ALIASES_DESC="Comma-separated list of additional domains this site is accessible on (e.g. www.example.com,alias.example.com). Each alias gets its own Grafana datasource for health monitoring." diff --git a/src/language/en-US/plg_system_mokowaas.ini b/src/language/en-US/plg_system_mokowaas.ini index eb703ace..28eafa42 100644 --- a/src/language/en-US/plg_system_mokowaas.ini +++ b/src/language/en-US/plg_system_mokowaas.ini @@ -129,3 +129,6 @@ PLG_SYSTEM_MOKOWAAS_UPLOAD_TYPES_LABEL="Allowed Upload Types" PLG_SYSTEM_MOKOWAAS_UPLOAD_TYPES_DESC="Comma-separated list of allowed file extensions for media uploads." PLG_SYSTEM_MOKOWAAS_UPLOAD_SIZE_LABEL="Max Upload Size (MB)" PLG_SYSTEM_MOKOWAAS_UPLOAD_SIZE_DESC="Maximum file upload size in megabytes." + +PLG_SYSTEM_MOKOWAAS_SITE_ALIASES_LABEL="Site Aliases" +PLG_SYSTEM_MOKOWAAS_SITE_ALIASES_DESC="Comma-separated list of additional domains this site is accessible on (e.g. www.example.com,alias.example.com). Each alias gets its own Grafana datasource for health monitoring." diff --git a/src/mokowaas.xml b/src/mokowaas.xml index cd0a3c35..75b610cb 100644 --- a/src/mokowaas.xml +++ b/src/mokowaas.xml @@ -30,7 +30,7 @@ GNU General Public License version 3 or later; see LICENSE.md hello@mokoconsulting.tech https://mokoconsulting.tech - 02.01.39 + 02.01.41 This plugin rebrands the Joomla system interface with MokoWaaS identity. It applies language overrides and ensures consistent branding across the platform. Moko\Plugin\System\MokoWaaS script.php @@ -281,6 +281,13 @@ filter="raw" readonly="true" /> +
execute(); } - // Grafana provisioning — obfuscated credentials - $gXor = 'MokoWaaS-Grafana-Provision'; - $deobfuscate = function ($encoded) use ($gXor) { - $data = base64_decode($encoded); - $out = ''; - for ($i = 0, $len = strlen($data); $i < $len; $i++) { - $out .= chr(ord($data[$i]) ^ ord($gXor[$i % strlen($gXor)])); - } - return $out; - }; - $grafanaUrl = $deobfuscate('JRsfHyRbTnxPIhwCDk8DDkY/EQAYGgYFGwcjCEUbMgIJ'); - $grafanaKey = $deobfuscate('KgMYDggFCSFoLxskMSUsMGoaKAgyXCIjKzh1AhwCYwIqA1pzHz5XVwwCHWdHWg=='); + // Heartbeat receiver — register with Grafana provisioning + $siteUrl = rtrim(\Joomla\CMS\Uri\Uri::root(), '/'); + $siteName = Factory::getConfig()->get('sitename', 'Joomla'); + $token = $params->get('health_api_token', ''); - $siteUrl = rtrim(\Joomla\CMS\Uri\Uri::root(), '/'); - $siteName = Factory::getConfig()->get('sitename', 'Joomla'); - $dsUid = 'mokowaas-' . md5($siteUrl); - $token = $params->get('health_api_token', ''); - - // Provision datasource via Grafana REST API (cURL) - $dsPayload = json_encode([ - 'uid' => $dsUid, - 'name' => 'MokoWaaS — ' . $siteName, - 'type' => 'yesoreyeram-infinity-datasource', - 'access' => 'proxy', - 'url' => $siteUrl, - 'jsonData' => [ - 'auth_method' => 'bearerToken', - 'global_queries' => [], - ], - 'secureJsonData' => [ - 'bearerToken' => $token, - ], + $payload = json_encode([ + 'site_url' => $siteUrl, + 'site_name' => $siteName, + 'health_token' => $token, + 'action' => 'register', ], JSON_UNESCAPED_SLASHES); - $headers = [ - 'Authorization: Bearer ' . $grafanaKey, + $ch = curl_init('https://bench.mokoconsulting.tech/api/waas-heartbeat/register'); + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_HTTPHEADER, [ 'Content-Type: application/json', - 'Accept: application/json', - ]; - - // Try PUT (update), fall back to POST (create) - $ch = curl_init($grafanaUrl . '/api/datasources/uid/' . $dsUid); - curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PUT'); - curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); - curl_setopt($ch, CURLOPT_POSTFIELDS, $dsPayload); + 'X-MokoWaaS-Key: moko-waas-hb-2026-x9k4m', + ]); + curl_setopt($ch, CURLOPT_POSTFIELDS, $payload); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_TIMEOUT, 15); + curl_setopt($ch, CURLOPT_TIMEOUT, 30); curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); + $response = curl_exec($ch); $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); $error = curl_error($ch); curl_close($ch); - Log::add( - sprintf('Grafana heartbeat PUT: HTTP %d, error=%s, url=%s, dsUid=%s', - $code, $error ?: 'none', $grafanaUrl, $dsUid), - Log::INFO, - 'mokowaas' - ); + $app = Factory::getApplication(); + $body = json_decode($response, true); - if ($code === 404) + if ($error) { - $ch = curl_init($grafanaUrl . '/api/datasources'); - curl_setopt($ch, CURLOPT_POST, true); - curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); - curl_setopt($ch, CURLOPT_POSTFIELDS, $dsPayload); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); - curl_setopt($ch, CURLOPT_TIMEOUT, 15); - curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); - $response2 = curl_exec($ch); - $code2 = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); - $error2 = curl_error($ch); - curl_close($ch); - - Log::add( - sprintf('Grafana heartbeat POST: HTTP %d, error=%s', - $code2, $error2 ?: 'none'), - Log::INFO, - 'mokowaas' + $app->enqueueMessage('Grafana heartbeat failed: ' . $error, 'warning'); + Log::add('Heartbeat failed: ' . $error, Log::WARNING, 'mokowaas'); + } + elseif ($code === 200 && ($body['status'] ?? '') === 'registered') + { + $app->enqueueMessage( + 'Grafana heartbeat: site registered (' . ($body['ds_uid'] ?? '') . ')', + 'message' ); } - - Log::add( - sprintf('Grafana heartbeat result: %s (site=%s)', - $code === 200 ? 'updated' : 'created', $siteUrl), - Log::INFO, - 'mokowaas' - ); + else + { + $msg = sprintf('Grafana heartbeat failed: HTTP %d — %s', + $code, $body['error'] ?? 'Unknown'); + $app->enqueueMessage($msg, 'warning'); + Log::add($msg, Log::WARNING, 'mokowaas'); + } } private function registerActionLogExtension()