diff --git a/README.md b/README.md index e3ec224..400a694 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.35 + VERSION: 02.01.36 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 88cc520..0a42c69 100644 --- a/src/Extension/MokoWaaS.php +++ b/src/Extension/MokoWaaS.php @@ -1090,25 +1090,89 @@ class MokoWaaS extends CMSPlugin // 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 +1193,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 +1310,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 +1441,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. *