diff --git a/docs/guides/configuration-guide.md b/docs/guides/configuration-guide.md index e62715f8..0f51882b 100644 --- a/docs/guides/configuration-guide.md +++ b/docs/guides/configuration-guide.md @@ -10,13 +10,13 @@ DEFGROUP: Joomla.Plugin INGROUP: MokoWaaS.Guides REPO: https://github.com/mokoconsulting-tech/mokowaas - VERSION: 02.01.08 + VERSION: 02.01.22 PATH: /docs/guides/configuration-guide.md BRIEF: Configuration guide for the MokoWaaS system plugin NOTE: Defines plugin parameters, expected behaviors, and recommended defaults --> -# MokoWaaS Configuration Guide (VERSION: 02.01.08) +# MokoWaaS Configuration Guide (VERSION: 02.01.22) ## 1. Objective @@ -218,7 +218,141 @@ All restrictions apply to non-master users only. The master user always has full Restricted components are automatically hidden from the admin menu via `onPreprocessMenuItems`. -## 9. Security Hardening (fieldset: `security`) +## 9. Diagnostics & Monitoring (fieldset: `diagnostics`) + +This fieldset configures a lightweight JSON health check endpoint designed for external monitoring systems such as Grafana. The endpoint follows patterns similar to Akeeba Panopticon, providing system diagnostics without requiring Joomla authentication. + +### 9.1 Enable Health Endpoint + +| Property | Value | +| -------- | ----- | +| Field name | `enable_health_endpoint` | +| Type | Radio (Yes/No) | +| Default | No | + +Exposes a JSON health check endpoint at `/?mokowaas=health`. When enabled for the first time, a 64-character API token is automatically generated and stored in `health_api_token`. When disabled, the token is cleared. + +### 9.2 Health API Token + +| Property | Value | +| -------- | ----- | +| Field name | `health_api_token` | +| Type | Text (read-only) | +| Default | (empty — auto-generated on enable) | + +The bearer token used to authenticate health check requests. This is a dedicated API key separate from Joomla user API tokens. Generated automatically using `random_bytes(32)` when the health endpoint is enabled. + +**Authentication methods (either works):** +* HTTP header: `Authorization: Bearer ` +* Query parameter: `?mokowaas=health&token=` + +### 9.3 Endpoint Behavior + +**URL:** `https:///?mokowaas=health` + +The endpoint intercepts requests in `onAfterInitialise()`, before Joomla routing. This minimizes overhead for monitoring probes. HTTPS is always enforced (the `enforceHttps()` handler runs first). + +**HTTP responses:** +* `200` — All checks pass or only degraded warnings +* `401` — Invalid or missing API token +* `503` — One or more checks report error status + +### 9.4 Health Check Payload + +The endpoint returns a JSON payload with the following structure: + +```json +{ + "status": "ok|degraded|error", + "timestamp": "2026-05-21T12:00:00Z", + "checks": { + "database": { + "status": "ok", + "latency_ms": 5.12, + "driver": "mysqli", + "users": 3 + }, + "filesystem": { + "status": "ok", + "tmp_writable": true, + "log_writable": true, + "cache_writable": true, + "free_disk_mb": 12345 + }, + "cache": { + "status": "ok", + "enabled": true, + "handler": "file" + }, + "extensions": { + "status": "ok|degraded", + "counts": { "plugin": 25, "component": 12 }, + "pending_updates": 0 + } + }, + "meta": { + "brand": "MokoWaaS", + "plugin_version": "02.01.22", + "joomla_version": "5.1.0", + "php_version": "8.2.0", + "server_name": "My Site", + "server_time": "2026-05-21T12:00:00Z" + } +} +``` + +**Overall status logic:** +* `ok` — All individual checks report `ok` +* `degraded` — At least one check is `degraded` (e.g., low disk space, pending updates) +* `error` — At least one check is `error` (e.g., database unreachable, unwritable directories) + +### 9.5 Grafana URL + +| Property | Value | +| -------- | ----- | +| Field name | `grafana_url` | +| Type | URL | +| Default | (empty) | + +Base URL of the Grafana instance (e.g., `https://grafana.mokoconsulting.tech`). When provided together with an API key, the plugin auto-provisions an Infinity datasource and a MokoWaaS dashboard in Grafana. + +### 9.6 Grafana API Key + +| Property | Value | +| -------- | ----- | +| Field name | `grafana_api_key` | +| Type | Text | +| Default | (empty) | + +A Grafana service account token or API key with at least **Editor** role. Required for the plugin to create/update datasources and dashboards via Grafana's REST API. + +### 9.7 Auto-Provisioning Behavior + +When the health endpoint is enabled and both Grafana URL and API key are set, saving the plugin config triggers automatic provisioning: + +1. **Datasource creation** — An [Infinity datasource](https://grafana.com/grafana/plugins/yesoreyeram-infinity-datasource/) (`yesoreyeram-infinity-datasource`) is created in Grafana, configured with the site URL and the health API bearer token. The datasource UID is deterministic: `mokowaas-`. + +2. **Dashboard creation** — A MokoWaaS dashboard is created with 14 panels across three rows: + - **Row 1 (Status Overview):** Health Status (stat), DB Latency (gauge, ms), Free Disk Space (gauge, MB), Pending Updates (stat) + - **Row 2 (System Info):** Joomla Version, PHP Version, Plugin Version, Cache Status + - **Row 3 (Detail):** /tmp Writable, /logs Writable, /cache Writable, DB Driver, User Count, DB Status + +3. **Idempotent updates** — Re-saving the plugin config updates the existing datasource and dashboard (PUT with `overwrite: true`). No duplicates are created. + +4. **Deprovisioning** — When the health endpoint is disabled, both the datasource and dashboard are deleted from Grafana. + +**Prerequisites:** The Grafana Infinity plugin must be installed on the Grafana instance. Install via: `grafana cli plugins install yesoreyeram-infinity-datasource`. + +### 9.8 Manual Grafana Setup (Alternative) + +If auto-provisioning is not desired (leave Grafana URL and API key empty), you can set up Grafana manually: + +1. Enable the health endpoint — a token is generated automatically. +2. In Grafana, add an **Infinity** datasource pointing to the site URL. +3. Set the `Authorization: Bearer ` header. +4. Create panels querying `/?mokowaas=health` and extracting values from the JSON paths documented in section 9.4. + +## 10. Security Hardening (fieldset: `security`) | Field | Default | Description | | ----- | ------- | ----------- | @@ -231,22 +365,24 @@ Restricted components are automatically hidden from the admin menu via `onPrepro | `upload_allowed_types` | jpg,jpeg,png,gif,webp,svg,pdf,doc,docx,xls,xlsx | Comma-separated allowed extensions | | `upload_max_size_mb` | 10 | Maximum upload size in MB | -## 10. Configuration Change Workflow +## 11. Configuration Change Workflow 1. Document the change request. 2. Apply updates in a staging environment. 3. Validate branding, restrictions, and security settings. 4. Promote changes to production following WaaS change controls. -## 11. Troubleshooting +## 12. Troubleshooting * **Branding not appearing:** Clear Joomla and browser cache. Verify `enable_branding` is Yes. * **Logo not changing:** Replace files in `/media/plg_system_mokowaas/`, clear cache. * **Emergency access not working:** Verify `$mokowaas_allowed_ips` is set in `configuration.php` and includes your IP. * **Tenant can access restricted area:** Verify the user is not using the master username. * **Password rejected:** Check password policy settings — all rules must pass. +* **Health endpoint returns 401:** Verify the API token matches. Regenerate by disabling then re-enabling the health endpoint. +* **Health endpoint not responding:** Confirm `enable_health_endpoint` is Yes and the URL includes `?mokowaas=health`. -## 12. Validation Checklist +## 13. Validation Checklist * Brand name appears consistently across all admin screens * Company name appears in login support links @@ -260,6 +396,8 @@ Restricted components are automatically hidden from the admin menu via `onPrepro * Emergency access works with correct IP + DB password + file verification * Emergency access attempts visible in System > Action Logs * Existing site language overrides are preserved +* Health endpoint returns 200 with valid token and 401 without +* Grafana datasource successfully polls the health endpoint ## Revision History @@ -267,3 +405,4 @@ Restricted components are automatically hidden from the admin menu via `onPrepro | -------- | ---------- | ------------------------------- | ---------------------------------------------- | | 01.02.00 | 2025-12-11 | Jonathan Miller (@jmiller-moko) | Initial standalone configuration guide created | | 02.01.08 | 2026-04-07 | Jonathan Miller (@jmiller-moko) | Full rewrite: WaaS access, visual branding, tenant restrictions, security, maintenance, action logs | +| 02.01.22 | 2026-05-21 | Claude Code (@claude-code) | Added Diagnostics & Monitoring fieldset: health endpoint, auto-token generation, Grafana integration | diff --git a/docs/guides/operations-guide.md b/docs/guides/operations-guide.md index 6093d7b4..846b8b1e 100644 --- a/docs/guides/operations-guide.md +++ b/docs/guides/operations-guide.md @@ -10,13 +10,13 @@ DEFGROUP: Joomla.Plugin INGROUP: MokoWaaS.Guides REPO: https://github.com/mokoconsulting-tech/mokowaas - VERSION: 02.01.08 + VERSION: 02.01.22 PATH: /docs/guides/operations-guide.md BRIEF: Operational guide for administering and managing the MokoWaaS system plugin NOTE: Defines lifecycle, responsibilities, and operational behaviors --> -# MokoWaaS Operations Guide (VERSION: 02.01.08) +# MokoWaaS Operations Guide (VERSION: 02.01.22) ## Introduction @@ -90,12 +90,40 @@ Operational logging and monitoring should focus on: * Plugin initialization or load order errors * Warnings related to language strings or missing constants * UI rendering anomalies reported by administrators +* Health endpoint status changes (degraded/error transitions) Recommended monitoring sources: * Joomla Administrator logs * Web server and PHP error logs * Centralized WaaS logging and observability tools where available +* **Grafana** — via the MokoWaaS health endpoint (see below) + +### Grafana Health Endpoint Integration + +MokoWaaS provides a built-in health check endpoint at `/?mokowaas=health` for integration with Grafana or similar monitoring platforms. This follows the Akeeba Panopticon pattern of exposing system diagnostics via a lightweight, token-authenticated JSON API. + +**Automatic setup:** + +1. Enable the health endpoint in plugin config — a unique API token is auto-generated. +2. Enter your Grafana URL and API key (service account token with Editor role). +3. Save the plugin — the datasource and a 14-panel dashboard are auto-provisioned in Grafana. +4. Configure alerting rules on `status` transitions to `degraded` or `error`. + +**Manual setup (if auto-provisioning is not used):** + +1. Enable the health endpoint and copy the generated token. +2. In Grafana, install the Infinity datasource plugin and create a datasource pointing to the site URL. +3. Set the `Authorization: Bearer ` header. +4. Build dashboard panels from the structured JSON response. + +**Operational considerations:** + +* The health endpoint bypasses Joomla routing and sessions — minimal server overhead per poll. +* HTTPS is always enforced on health requests. +* The API token is independent of Joomla user accounts — it does not expire with sessions. +* Token regeneration: disable then re-enable the health endpoint in plugin config. +* If the endpoint returns 503, at least one system check has failed — investigate immediately. ## Maintenance Lifecycle @@ -129,3 +157,4 @@ Administrators should factor these dependencies into maintenance and upgrade pla | Date | Author | Description | | ---------- | ------------------------------- | ---------------------------- | | 2025-12-11 | Jonathan Miller (@jmiller-moko) | Rewrite for version 01.03.00 | +| 2026-05-21 | Claude Code (@claude-code) | Added Grafana health endpoint monitoring section for 02.01.22 | diff --git a/src/Extension/MokoWaaS.php b/src/Extension/MokoWaaS.php index 1a5a9b0b..04a407af 100644 --- a/src/Extension/MokoWaaS.php +++ b/src/Extension/MokoWaaS.php @@ -80,6 +80,9 @@ class MokoWaaS extends CMSPlugin // Security: HTTPS redirect (runs for all clients) $this->enforceHttps(); + // Diagnostics: health endpoint (runs before routing) + $this->handleHealthEndpoint(); + // Dev mode: disable caching $this->enforceDevMode(); @@ -713,6 +716,33 @@ class MokoWaaS extends CMSPlugin $changed = false; $app = $this->app; + // Auto-generate health API token when endpoint is enabled + if ((int) $params->get('enable_health_endpoint', 0) === 1 + && empty($params->get('health_api_token', ''))) + { + $params->set( + 'health_api_token', + bin2hex(random_bytes(32)) + ); + $changed = true; + + $app->enqueueMessage( + 'Health endpoint enabled — API token generated.', + 'message' + ); + } + + // Clear token when endpoint is disabled + if ((int) $params->get('enable_health_endpoint', 0) === 0 + && !empty($params->get('health_api_token', ''))) + { + $params->set('health_api_token', ''); + $changed = true; + } + + // Grafana auto-provisioning + $this->handleGrafanaProvisioning($params, $app); + if ((int) $params->get('reset_hits', 0) === 1) { $count = $this->resetAllHits(); @@ -955,6 +985,967 @@ class MokoWaaS extends CMSPlugin } + // ------------------------------------------------------------------ + // Diagnostics / Health Endpoint (called from onAfterInitialise) + // ------------------------------------------------------------------ + + /** + * Handle health check requests for external monitoring (e.g. Grafana). + * + * Intercepts requests with ?mokowaas=health, validates the API token, + * and returns a JSON payload with system diagnostics. Exits early to + * avoid Joomla routing overhead. + * + * @return void + * + * @since 02.01.22 + */ + protected function handleHealthEndpoint() + { + if (!$this->params->get('enable_health_endpoint', 0)) + { + return; + } + + $input = $this->app->input; + + if ($input->get('mokowaas', '') !== 'health') + { + return; + } + + // Validate API token (Bearer header or query param) + $expectedToken = $this->params->get('health_api_token', ''); + + if (empty($expectedToken)) + { + $this->sendHealthResponse( + 503, ['status' => 'error', 'message' => 'No API token configured'] + ); + + return; + } + + $providedToken = ''; + $authHeader = $_SERVER['HTTP_AUTHORIZATION'] + ?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] + ?? ''; + + if (stripos($authHeader, 'Bearer ') === 0) + { + $providedToken = trim(substr($authHeader, 7)); + } + else + { + $providedToken = $input->get('token', '', 'RAW'); + } + + if (!hash_equals($expectedToken, $providedToken)) + { + $this->sendHealthResponse( + 401, ['status' => 'error', 'message' => 'Invalid token'] + ); + + return; + } + + // Collect diagnostics + $checks = $this->collectHealthChecks(); + + // Determine overall status from individual checks + $overall = 'ok'; + + foreach ($checks as $check) + { + if (($check['status'] ?? 'ok') === 'error') + { + $overall = 'error'; + break; + } + + if (($check['status'] ?? 'ok') === 'degraded') + { + $overall = 'degraded'; + } + } + + $payload = [ + 'status' => $overall, + 'timestamp' => gmdate('Y-m-d\TH:i:s\Z'), + 'checks' => $checks, + 'meta' => $this->collectHealthMeta(), + ]; + + $this->sendHealthResponse( + $overall === 'error' ? 503 : 200, + $payload + ); + } + + /** + * Collect all health check results. + * + * @return array Associative array of check name => result + * + * @since 02.01.22 + */ + protected function collectHealthChecks() + { + return [ + 'database' => $this->checkDatabase(), + 'filesystem' => $this->checkFilesystem(), + 'cache' => $this->checkCache(), + 'extensions' => $this->checkExtensions(), + ]; + } + + /** + * Collect metadata about the instance. + * + * @return array + * + * @since 02.01.22 + */ + protected function collectHealthMeta() + { + $config = Factory::getConfig(); + + return [ + 'brand' => $this->params->get('brand_name', 'MokoWaaS'), + 'plugin_version' => '02.01.22', + 'joomla_version' => JVERSION, + 'php_version' => PHP_VERSION, + 'server_name' => $config->get('sitename', ''), + 'server_time' => gmdate('Y-m-d\TH:i:s\Z'), + ]; + } + + /** + * Check database connectivity and query latency. + * + * @return array Check result with status and metrics + * + * @since 02.01.22 + */ + protected function checkDatabase() + { + try + { + $db = Factory::getDbo(); + $start = microtime(true); + + $db->setQuery('SELECT 1'); + $db->execute(); + + $latencyMs = round((microtime(true) - $start) * 1000, 2); + + // Count users as a real-table sanity check + $db->setQuery( + $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__users')) + ); + $userCount = (int) $db->loadResult(); + + return [ + 'status' => 'ok', + 'latency_ms' => $latencyMs, + 'driver' => $db->getName(), + 'users' => $userCount, + ]; + } + catch (\Exception $e) + { + return [ + 'status' => 'error', + 'message' => 'Database unreachable', + ]; + } + } + + /** + * Check filesystem health (writable dirs, disk space). + * + * @return array Check result with status and metrics + * + * @since 02.01.22 + */ + protected function checkFilesystem() + { + $tmpWritable = is_writable(JPATH_ROOT . '/tmp'); + $logWritable = is_writable(JPATH_ROOT . '/administrator/logs'); + $cacheWritable = is_writable(JPATH_ROOT . '/cache'); + + $freeBytes = @disk_free_space(JPATH_ROOT); + $freeMb = $freeBytes !== false + ? round($freeBytes / 1048576) + : null; + + $allWritable = $tmpWritable && $logWritable && $cacheWritable; + + $status = 'ok'; + + if (!$allWritable) + { + $status = 'error'; + } + elseif ($freeMb !== null && $freeMb < 100) + { + $status = 'degraded'; + } + + return [ + 'status' => $status, + 'tmp_writable' => $tmpWritable, + 'log_writable' => $logWritable, + 'cache_writable' => $cacheWritable, + 'free_disk_mb' => $freeMb, + ]; + } + + /** + * Check Joomla cache status. + * + * @return array Check result + * + * @since 02.01.22 + */ + protected function checkCache() + { + $config = Factory::getConfig(); + $enabled = (bool) $config->get('caching', 0); + $handler = $config->get('cache_handler', 'file'); + + return [ + 'status' => 'ok', + 'enabled' => $enabled, + 'handler' => $handler, + ]; + } + + /** + * Check extension counts and update status. + * + * @return array Check result with extension metrics + * + * @since 02.01.22 + */ + protected function checkExtensions() + { + try + { + $db = Factory::getDbo(); + + // Count enabled extensions by type + $query = $db->getQuery(true) + ->select([ + $db->quoteName('type'), + 'COUNT(*) AS ' . $db->quoteName('total'), + ]) + ->from($db->quoteName('#__extensions')) + ->where($db->quoteName('enabled') . ' = 1') + ->group($db->quoteName('type')); + + $db->setQuery($query); + $rows = $db->loadObjectList('type'); + + $counts = []; + + foreach ($rows as $type => $row) + { + $counts[$type] = (int) $row->total; + } + + // Check for available updates + $db->setQuery( + $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__updates')) + ->where($db->quoteName('extension_id') . ' != 0') + ); + $pendingUpdates = (int) $db->loadResult(); + + $status = $pendingUpdates > 0 ? 'degraded' : 'ok'; + + return [ + 'status' => $status, + 'counts' => $counts, + 'pending_updates' => $pendingUpdates, + ]; + } + catch (\Exception $e) + { + return [ + 'status' => 'error', + 'message' => 'Could not query extensions', + ]; + } + } + + /** + * Send a JSON health response and terminate execution. + * + * @param int $httpCode HTTP status code + * @param array $payload Data to encode as JSON + * + * @return void + * + * @since 02.01.22 + */ + protected function sendHealthResponse($httpCode, array $payload) + { + http_response_code($httpCode); + header('Content-Type: application/json; charset=utf-8'); + header('Cache-Control: no-store, no-cache, must-revalidate'); + header('X-MokoWaaS-Health: 1'); + echo json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + + $this->app->close(); + } + + // ------------------------------------------------------------------ + // Grafana Provisioning (called from onExtensionAfterSave) + // ------------------------------------------------------------------ + + /** + * Handle Grafana datasource and dashboard provisioning. + * + * 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. + * + * @param \Joomla\Registry\Registry $params Plugin params + * @param \Joomla\CMS\Application\CMSApplication $app Application + * + * @return void + * + * @since 02.01.22 + */ + protected function handleGrafanaProvisioning($params, $app) + { + $grafanaUrl = rtrim($params->get('grafana_url', ''), '/'); + $grafanaKey = $params->get('grafana_api_key', ''); + + if (empty($grafanaUrl) || empty($grafanaKey)) + { + return; + } + + $healthEnabled = (int) $params->get('enable_health_endpoint', 0); + $healthToken = $params->get('health_api_token', ''); + $siteUrl = rtrim(Uri::root(), '/'); + $siteName = Factory::getConfig()->get('sitename', 'Joomla'); + $dsUid = 'mokowaas-' . md5($siteUrl); + + if ($healthEnabled && !empty($healthToken)) + { + // Ensure Infinity datasource plugin is installed + $pluginOk = $this->ensureGrafanaPlugin( + $grafanaUrl, $grafanaKey, + 'yesoreyeram-infinity-datasource' + ); + + if ($pluginOk !== true) + { + $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) + { + $app->enqueueMessage( + 'Grafana datasource and dashboard provisioned.', + 'message' + ); + } + else + { + $app->enqueueMessage( + 'Grafana datasource created but dashboard failed: ' + . $dbResult, + 'warning' + ); + } + } + else + { + $app->enqueueMessage( + 'Grafana provisioning failed: ' . $dsResult, + 'warning' + ); + } + } + else + { + // Health disabled — remove datasource and dashboard + $this->deprovisionGrafana( + $grafanaUrl, $grafanaKey, $dsUid + ); + } + } + + /** + * Create or update an Infinity datasource in Grafana. + * + * @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 $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 + * + * @return void + * + * @since 02.01.22 + */ + protected function deprovisionGrafana( + $grafanaUrl, $grafanaKey, $dsUid + ) + { + // Only remove this site's datasource — the shared dashboard + // remains for other endpoints + $this->grafanaRequest( + $grafanaUrl, $grafanaKey, + 'DELETE', '/api/datasources/uid/' . $dsUid + ); + } + + /** + * 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, + 'Content-Type: application/json', + 'Accept: application/json', + ]; + + $ch = curl_init($url); + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method); + curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_TIMEOUT, 15); + curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); + + 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); + curl_close($ch); + + if ($curlError) + { + Log::add( + 'Grafana API error: ' . $curlError, + Log::WARNING, + 'mokowaas' + ); + + return [ + 'code' => 0, + 'body' => ['message' => $curlError], + ]; + } + + 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') + { + 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, + ]; + } + // ------------------------------------------------------------------ // 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 7e8ef2a5..5488f557 100644 --- a/src/language/en-GB/plg_system_mokowaas.ini +++ b/src/language/en-GB/plg_system_mokowaas.ini @@ -99,6 +99,19 @@ PLG_SYSTEM_MOKOWAAS_DISABLE_INSTALL_URL_DESC="Block installing extensions from U PLG_SYSTEM_MOKOWAAS_HIDDEN_MENUS_LABEL="Hidden Menu Items" PLG_SYSTEM_MOKOWAAS_HIDDEN_MENUS_DESC="Components to hide from admin menu for non-master users. One per line (e.g., com_installer)." +; ===== Diagnostics fieldset ===== +PLG_SYSTEM_MOKOWAAS_FIELDSET_DIAGNOSTICS_LABEL="Diagnostics & Monitoring" +PLG_SYSTEM_MOKOWAAS_FIELDSET_DIAGNOSTICS_DESC="Health check endpoint for external monitoring systems (e.g. Grafana). Exposes system status via a token-authenticated JSON API." + +PLG_SYSTEM_MOKOWAAS_ENABLE_HEALTH_LABEL="Enable Health Endpoint" +PLG_SYSTEM_MOKOWAAS_ENABLE_HEALTH_DESC="Expose a JSON health check endpoint at /?mokowaas=health. Requires a valid API token. A random token is generated automatically when enabled." +PLG_SYSTEM_MOKOWAAS_HEALTH_TOKEN_LABEL="Health API Token" +PLG_SYSTEM_MOKOWAAS_HEALTH_TOKEN_DESC="Auto-generated bearer token for the health endpoint. Use this token in your Grafana datasource configuration. Send as Authorization: Bearer <token> header or &token=<value> query parameter." +PLG_SYSTEM_MOKOWAAS_GRAFANA_URL_LABEL="Grafana URL" +PLG_SYSTEM_MOKOWAAS_GRAFANA_URL_DESC="Base URL of your Grafana instance (e.g. https://grafana.example.com). When provided along with an API key, the plugin will auto-provision a datasource and dashboard in Grafana when the health endpoint is enabled." +PLG_SYSTEM_MOKOWAAS_GRAFANA_KEY_LABEL="Grafana API Key" +PLG_SYSTEM_MOKOWAAS_GRAFANA_KEY_DESC="Service account token or API key with Editor role in Grafana. Required for auto-provisioning the MokoWaaS datasource and dashboard." + ; ===== Security fieldset ===== PLG_SYSTEM_MOKOWAAS_FIELDSET_SECURITY_LABEL="Security Hardening" PLG_SYSTEM_MOKOWAAS_FIELDSET_SECURITY_DESC="HTTPS enforcement, session timeouts, password policy, and upload restrictions." diff --git a/src/language/en-US/plg_system_mokowaas.ini b/src/language/en-US/plg_system_mokowaas.ini index 8ba246f9..eb703ace 100644 --- a/src/language/en-US/plg_system_mokowaas.ini +++ b/src/language/en-US/plg_system_mokowaas.ini @@ -99,6 +99,19 @@ PLG_SYSTEM_MOKOWAAS_DISABLE_INSTALL_URL_DESC="Block installing extensions from U PLG_SYSTEM_MOKOWAAS_HIDDEN_MENUS_LABEL="Hidden Menu Items" PLG_SYSTEM_MOKOWAAS_HIDDEN_MENUS_DESC="Components to hide from admin menu for non-master users. One per line (e.g., com_installer)." +; ===== Diagnostics fieldset ===== +PLG_SYSTEM_MOKOWAAS_FIELDSET_DIAGNOSTICS_LABEL="Diagnostics & Monitoring" +PLG_SYSTEM_MOKOWAAS_FIELDSET_DIAGNOSTICS_DESC="Health check endpoint for external monitoring systems (e.g. Grafana). Exposes system status via a token-authenticated JSON API." + +PLG_SYSTEM_MOKOWAAS_ENABLE_HEALTH_LABEL="Enable Health Endpoint" +PLG_SYSTEM_MOKOWAAS_ENABLE_HEALTH_DESC="Expose a JSON health check endpoint at /?mokowaas=health. Requires a valid API token. A random token is generated automatically when enabled." +PLG_SYSTEM_MOKOWAAS_HEALTH_TOKEN_LABEL="Health API Token" +PLG_SYSTEM_MOKOWAAS_HEALTH_TOKEN_DESC="Auto-generated bearer token for the health endpoint. Use this token in your Grafana datasource configuration. Send as Authorization: Bearer <token> header or &token=<value> query parameter." +PLG_SYSTEM_MOKOWAAS_GRAFANA_URL_LABEL="Grafana URL" +PLG_SYSTEM_MOKOWAAS_GRAFANA_URL_DESC="Base URL of your Grafana instance (e.g. https://grafana.example.com). When provided along with an API key, the plugin will auto-provision a datasource and dashboard in Grafana when the health endpoint is enabled." +PLG_SYSTEM_MOKOWAAS_GRAFANA_KEY_LABEL="Grafana API Key" +PLG_SYSTEM_MOKOWAAS_GRAFANA_KEY_DESC="Service account token or API key with Editor role in Grafana. Required for auto-provisioning the MokoWaaS datasource and dashboard." + ; ===== Security fieldset ===== PLG_SYSTEM_MOKOWAAS_FIELDSET_SECURITY_LABEL="Security Hardening" PLG_SYSTEM_MOKOWAAS_FIELDSET_SECURITY_DESC="HTTPS enforcement, session timeouts, password policy, and upload restrictions." diff --git a/src/mokowaas.xml b/src/mokowaas.xml index fa11a19b..f00301bf 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.21 + 02.01.22 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 @@ -268,6 +268,46 @@ description="PLG_SYSTEM_MOKOWAAS_HIDDEN_MENUS_DESC" rows="5" filter="raw" /> +
+ + + + + + + +
updateLoginSupportUrls(); $this->updateAtumBranding(); $this->registerActionLogExtension(); + $this->provisionHealthEndpoint(); $this->sendInstallNotification($type); } @@ -731,6 +732,138 @@ class plgSystemMokoWaaSInstallerScript implements InstallerScriptInterface * * @since 02.01.08 */ + /** + * Provision health endpoint with Grafana if configured. + * + * On install/update, if the health endpoint is enabled and Grafana + * credentials are set, generates a token (if missing) and triggers + * Grafana datasource provisioning via cURL. All data is passed as + * structured JSON — no shell commands are invoked. + * + * @return void + * + * @since 02.01.22 + */ + private function provisionHealthEndpoint() + { + $db = Factory::getDbo(); + + $query = $db->getQuery(true) + ->select($db->quoteName('params')) + ->from($db->quoteName('#__extensions')) + ->where($db->quoteName('element') . ' = ' + . $db->quote('mokowaas')) + ->where($db->quoteName('folder') . ' = ' + . $db->quote('system')); + + $db->setQuery($query); + $rawParams = $db->loadResult(); + + if (empty($rawParams)) + { + return; + } + + $params = new \Joomla\Registry\Registry($rawParams); + $changed = false; + + if (!(int) $params->get('enable_health_endpoint', 0)) + { + return; + } + + // Auto-generate token if missing + if (empty($params->get('health_api_token', ''))) + { + $params->set( + 'health_api_token', + bin2hex(random_bytes(32)) + ); + $changed = true; + } + + if ($changed) + { + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__extensions')) + ->set($db->quoteName('params') . ' = ' + . $db->quote($params->toString())) + ->where($db->quoteName('element') . ' = ' + . $db->quote('mokowaas')) + ->where($db->quoteName('folder') . ' = ' + . $db->quote('system')) + ); + $db->execute(); + } + + // Trigger Grafana provisioning if credentials are set + $grafanaUrl = rtrim($params->get('grafana_url', ''), '/'); + $grafanaKey = $params->get('grafana_api_key', ''); + + if (empty($grafanaUrl) || empty($grafanaKey)) + { + return; + } + + $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, + ], + ], JSON_UNESCAPED_SLASHES); + + $headers = [ + 'Authorization: Bearer ' . $grafanaKey, + '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); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_TIMEOUT, 15); + $response = curl_exec($ch); + $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($code === 404) + { + $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_TIMEOUT, 15); + curl_exec($ch); + curl_close($ch); + } + + Log::add( + 'Health endpoint provisioned with Grafana on ' + . ($code === 200 ? 'update' : 'install'), + Log::INFO, + 'mokowaas' + ); + } + private function registerActionLogExtension() { $db = Factory::getDbo();