diff --git a/CLAUDE.md b/CLAUDE.md index 26547ca..c5e8f23 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,5 +38,5 @@ This is a Joomla extension. Key directories: - **Attribution**: use `Authored-by: Moko Consulting` in commits - **Branch strategy**: develop on `dev`, merge to `main` for release - **Minification**: handled at build time (CI) and runtime (MokoMinifyHelper for Joomla templates) -- **Wiki**: documentation lives in the Gitea wiki, not in `docs/` files +- **Wiki**: documentation lives in the MokoGitea wiki, not in `docs/` files - **Standards**: this repo follows [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/moko-platform/wiki/Home) diff --git a/README.md b/README.md index e3ec224..ac0299b 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.39 PATH: /README.md BRIEF: Rebranding plugin for MokoWaaS platform NOTE: Internal WaaS identity abstraction layer diff --git a/docs/update-server.md b/docs/update-server.md index 5bfe032..f47faf8 100644 --- a/docs/update-server.md +++ b/docs/update-server.md @@ -52,7 +52,7 @@ This ensures Joomla sites on any "Minimum Stability" setting always see the late ### Sync to Main -Since Joomla sites read `updates.xml` from the `main` branch, the `update-server.yml` workflow **syncs `updates.xml` to `main` via the Gitea API** after building on non-main branches. This ensures pre-release channel entries are visible to sites checking for updates without requiring a PR merge to main. +Since Joomla sites read `updates.xml` from the `main` branch, the `update-server.yml` workflow **syncs `updates.xml` to `main` via the MokoGitea API** after building on non-main branches. This ensures pre-release channel entries are visible to sites checking for updates without requiring a PR merge to main. ### Generated XML Structure @@ -120,7 +120,7 @@ dev → [alpha] → [beta] → rc → version/XX → main → dev optional optional (integration) (production) (feedback) ``` -1. **Development** (`dev` or `dev/**`): `updates.xml` with `development`, download points to Gitea release ZIP +1. **Development** (`dev` or `dev/**`): `updates.xml` with `development`, download points to MokoGitea release ZIP 2. **Alpha** (`alpha/**`): `updates.xml` with `alpha`, cascades to development channel 3. **Beta** (`beta/**`): `updates.xml` with `beta`, cascades to alpha + development channels 4. **Release Candidate** (`rc/**`): `updates.xml` with `rc`, cascades to beta + alpha + development channels diff --git a/src/Extension/MokoWaaS.php b/src/Extension/MokoWaaS.php index 88cc520..9b89002 100644 --- a/src/Extension/MokoWaaS.php +++ b/src/Extension/MokoWaaS.php @@ -50,51 +50,20 @@ use Joomla\CMS\User\UserHelper; class MokoWaaS extends CMSPlugin { /** - * Obfuscated Grafana URL (XOR + base64). + * Heartbeat receiver URL for Grafana provisioning. * * @var string - * @since 02.01.26 + * @since 02.01.36 */ - 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. @@ -133,6 +102,9 @@ class MokoWaaS extends CMSPlugin // Dev mode: disable caching $this->enforceDevMode(); + // Self-healing: re-lock if someone tampered + $this->enforceLocked(); + // Admin-only WaaS controls if ($this->app->isClient('administrator')) { @@ -919,6 +891,12 @@ class MokoWaaS extends CMSPlugin } $this->injectFavicon($doc); + + // Hide MokoWaaS from plugin list for non-master users + if (!$this->isMasterUser()) + { + $this->hidePluginFromList($doc); + } } /** @@ -1341,636 +1319,163 @@ class MokoWaaS extends CMSPlugin $this->app->close(); } + // ------------------------------------------------------------------ - // Grafana Provisioning (called from onExtensionAfterSave) + // Heartbeat (called from onExtensionAfterSave) // ------------------------------------------------------------------ /** - * Handle Grafana datasource and dashboard provisioning. + * Send heartbeat to the MokoWaaS monitoring receiver. * - * 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. + * Registers this site 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) - { - $app->enqueueMessage( - 'Grafana plugin install failed: ' . $pluginOk - . ' — install the Infinity plugin manually.', - 'warning' - ); + $payload = json_encode([ + 'site_url' => $siteUrl, + 'site_name' => $siteName, + 'health_token' => $healthToken, + 'action' => 'register', + ], JSON_UNESCAPED_SLASHES); - 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' - ); - } - } - - /** - * 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, + $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) + if ($error) { - Log::add( - 'Grafana API error: ' . $curlError, - Log::WARNING, - 'mokowaas' + $app->enqueueMessage( + 'Grafana heartbeat failed: ' . $error, + 'warning' ); + Log::add('Heartbeat failed: ' . $error, Log::WARNING, 'mokowaas'); - return [ - 'code' => 0, - 'body' => ['message' => $curlError], - ]; + return; } - return [ - 'code' => $httpCode, - 'body' => json_decode($responseBody, true) ?: [], - ]; + $body = json_decode($response, true); + + if ($code === 200 && ($body['status'] ?? '') === 'registered') + { + $app->enqueueMessage( + 'Grafana heartbeat: site registered (' . ($body['ds_uid'] ?? '') . ')', + 'message' + ); + } + else + { + $msg = sprintf( + 'Grafana heartbeat failed: HTTP %d — %s', + $code, $body['error'] ?? $body['message'] ?? 'Unknown' + ); + $app->enqueueMessage($msg, 'warning'); + Log::add($msg, Log::WARNING, 'mokowaas'); + } } + // ------------------------------------------------------------------ + // Self-Protection (called from onAfterInitialise) + // ------------------------------------------------------------------ + /** - * Build the Grafana dashboard JSON model. + * Ensure the plugin stays enabled, locked, and protected. * - * Creates a dashboard with panels for all health check metrics: - * overall status, database latency, disk space, extensions, - * Joomla/PHP versions, and cache status. + * Re-applies protection flags on every request. If someone manually + * disables or unlocks the plugin via the database, this re-enables + * it on the next page load. * - * @param string $dsUid Datasource UID - * @param string $siteName Joomla site name + * @return void * - * @return array Grafana dashboard model - * - * @since 02.01.22 + * @since 02.01.36 */ - protected function buildDashboardModel($dsUid, $siteName) + protected function enforceLocked() { - // 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. + static $checked = false; - $mkQuery = function ($refId, $jsonPath, $type = 'string') + if ($checked) { - 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, - ], - ], - ]; - }; + return; + } - $panels = []; - $panelId = 1; + $checked = true; - // --- Row 1: Status overview --- + $db = Factory::getDbo(); + $query = $db->getQuery(true) + ->select([ + $db->quoteName('enabled'), + $db->quoteName('locked'), + $db->quoteName('protected'), + ]) + ->from($db->quoteName('#__extensions')) + ->where($db->quoteName('element') . ' = ' + . $db->quote('mokowaas')) + ->where($db->quoteName('folder') . ' = ' + . $db->quote('system')); - $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'], - ]], - ], - ], - ], - ]; + $db->setQuery($query); + $ext = $db->loadObject(); - $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'], - ], - ], - ], - ], - ]; + if (!$ext) + { + return; + } - $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'], - ], - ], - ], - ], - ]; + if ((int) $ext->enabled === 1 + && (int) $ext->locked === 1 + && (int) $ext->protected === 1) + { + return; + } - $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'], - ], - ], - ], - ], - ]; + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__extensions')) + ->set($db->quoteName('enabled') . ' = 1') + ->set($db->quoteName('locked') . ' = 1') + ->set($db->quoteName('protected') . ' = 1') + ->where($db->quoteName('element') . ' = ' + . $db->quote('mokowaas')) + ->where($db->quoteName('folder') . ' = ' + . $db->quote('system')) + ); + $db->execute(); - // --- 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, - ]; + Log::add( + 'MokoWaaS self-healed: re-locked plugin after tampering', + Log::WARNING, + 'mokowaas' + ); } // ------------------------------------------------------------------ @@ -2182,6 +1687,36 @@ class MokoWaaS extends CMSPlugin $view = $input->get('view', ''); $task = $input->get('task', ''); + // MokoWaaS plugin settings — master user only + if ($option === 'com_plugins' + && !$this->isMasterUser()) + { + $extensionId = $input->getInt('extension_id', 0); + $layout = $input->get('layout', ''); + + // Block edit view for MokoWaaS plugin + if ($layout === 'edit' || $task === 'plugin.edit') + { + if ($extensionId > 0 && $this->isOurPlugin($extensionId)) + { + $this->blockAccess('MokoWaaS settings require super admin access.'); + + return; + } + } + + // Block save attempts + if ($task === 'plugin.apply' || $task === 'plugin.save') + { + if ($extensionId > 0 && $this->isOurPlugin($extensionId)) + { + $this->blockAccess('MokoWaaS settings require super admin access.'); + + return; + } + } + } + // Disable install-from-URL for ALL users (safety net) if ($this->params->get('disable_install_url', 1) && $option === 'com_installer' @@ -2293,6 +1828,32 @@ class MokoWaaS extends CMSPlugin return $user->username === $masterUsername; } + /** + * Check whether an extension ID belongs to the MokoWaaS plugin. + * + * @param int $extensionId Extension ID to check + * + * @return boolean + * + * @since 02.01.38 + */ + protected function isOurPlugin(int $extensionId): bool + { + $db = Factory::getDbo(); + $query = $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__extensions')) + ->where($db->quoteName('extension_id') . ' = ' . $extensionId) + ->where($db->quoteName('element') . ' = ' + . $db->quote('mokowaas')) + ->where($db->quoteName('folder') . ' = ' + . $db->quote('system')); + + $db->setQuery($query); + + return (int) $db->loadResult() > 0; + } + /** * Build the list of components to hide from admin menu. * @@ -2506,6 +2067,41 @@ class MokoWaaS extends CMSPlugin * * @since 02.01.08 */ + /** + * Hide MokoWaaS from the Joomla plugin list for non-master users. + * + * Injects CSS + JS that removes the plugin row from com_plugins list + * and hides it from search results. Only runs when on the plugins page. + * + * @param \Joomla\CMS\Document\HtmlDocument $doc + * + * @return void + * + * @since 02.01.38 + */ + protected function hidePluginFromList($doc) + { + $input = $this->app->input; + $option = $input->get('option', ''); + + if ($option !== 'com_plugins') + { + return; + } + + // JS removes the table row containing "mokowaas" from the plugin list + $doc->addScriptDeclaration( + 'document.addEventListener("DOMContentLoaded", function() {' + . ' document.querySelectorAll("table.table tbody tr").forEach(function(row) {' + . ' if (row.textContent.indexOf("mokowaas") !== -1' + . ' || row.textContent.indexOf("MokoWaaS") !== -1) {' + . ' row.style.display = "none";' + . ' }' + . ' });' + . '});' + ); + } + protected function injectFavicon($doc) { $mediaBase = 'media/plg_system_mokowaas/'; diff --git a/src/script.php b/src/script.php index 231800a..fedec61 100644 --- a/src/script.php +++ b/src/script.php @@ -84,6 +84,17 @@ class plgSystemMokoWaaSInstallerScript implements InstallerScriptInterface */ public function preflight($type, $adapter): bool { + // Block uninstallation — MokoWaaS is a required system component + if ($type === 'uninstall') + { + Factory::getApplication()->enqueueMessage( + 'MokoWaaS is a required system plugin and cannot be uninstalled.', + 'error' + ); + + return false; + } + // Check minimum Joomla version if (version_compare(JVERSION, $this->minimumJoomla, '<')) { @@ -792,96 +803,60 @@ class plgSystemMokoWaaSInstallerScript implements InstallerScriptInterface $db->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 + $heartbeatUrl = 'https://bench.mokoconsulting.tech/api/waas-heartbeat/register'; + $heartbeatKey = 'moko-waas-hb-2026-x9k4m'; - $siteUrl = rtrim(\Joomla\CMS\Uri\Uri::root(), '/'); - $siteName = Factory::getConfig()->get('sitename', 'Joomla'); - $dsUid = 'mokowaas-' . md5($siteUrl); - $token = $params->get('health_api_token', ''); + $siteUrl = rtrim(\Joomla\CMS\Uri\Uri::root(), '/'); + $siteName = Factory::getConfig()->get('sitename', 'Joomla'); + $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($heartbeatUrl); + 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: ' . $heartbeatKey, + ]); + 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() diff --git a/updates.xml b/updates.xml index 0870e0f..9bc67c9 100644 --- a/updates.xml +++ b/updates.xml @@ -1,7 +1,7 @@ @@ -10,15 +10,15 @@ System - MokoWaaS update mokowaas plugin - 02.01.35-dev + 02.01.39-dev site system development https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS/releases/tag/development - https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS/releases/download/development/plg_system_mokowaas-02.01.35-dev.zip + https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS/releases/download/development/plg_system_mokowaas-02.01.39-dev.zip - a21fd00f1197558c9158deea9ecc34f8c79b6a1c034a0d0b49f3da45b3965bc9 + 66ff564ab57221ab50a4a9086acddbe9451e157a3025e70e50ddcd2d14a65011 Moko Consulting https://mokoconsulting.tech