diff --git a/application/controllers/DaemonController.php b/application/controllers/DaemonController.php index fd1bad02f..8e252c7ce 100644 --- a/application/controllers/DaemonController.php +++ b/application/controllers/DaemonController.php @@ -8,6 +8,8 @@ final class DaemonController extends CompatController { + protected $requiresAuthentication = false; + public function init(): void { /** @@ -40,7 +42,7 @@ public function scriptAction(): void ->getBaseDir() . '/public/js'; $filePath = realpath( - $root . DIRECTORY_SEPARATOR . 'icinga-notifications-' . $this->_getParam( + $root . DIRECTORY_SEPARATOR . 'notifications-' . $this->_getParam( 'file', 'undefined' ) . $this->_getParam('extension', 'undefined') @@ -50,7 +52,7 @@ public function scriptAction(): void $this->httpNotFound("No file name submitted"); } $this->httpNotFound( - "'icinga-notifications-" + "'notifications-" . $this->_getParam('file') . $this->_getParam('extension') . " does not exist" @@ -86,7 +88,7 @@ public function scriptAction(): void } } else { $this->httpNotFound( - "'icinga-notifications-" + "'notifications-" . $this->_getParam('file') . $this->_getParam('extension') . " could not be read" diff --git a/configuration.php b/configuration.php index d916da64d..1315fc033 100644 --- a/configuration.php +++ b/configuration.php @@ -77,4 +77,4 @@ ] ); -$this->provideJsFile('notification.js'); +$this->provideJsFile('notifications.js'); diff --git a/library/Notifications/Daemon/Daemon.php b/library/Notifications/Daemon/Daemon.php index 9dd9db62e..37dc939d5 100644 --- a/library/Notifications/Daemon/Daemon.php +++ b/library/Notifications/Daemon/Daemon.php @@ -246,21 +246,23 @@ private function processNotifications(): void $time->setTimezone(new DateTimeZone('UTC')); $time = $time->format(DateTimeInterface::RFC3339_EXTENDED); + $event = new Event( + EventIdentifier::ICINGA2_NOTIFICATION, + (object) [ + 'incident_id' => $incident->incident_id, + 'event_id' => $incident->event_id, + 'host' => $host, + 'service' => $service, + 'time' => $time, + 'severity' => $incident->incident->severity + ], + // minus one as it's usually expected as an auto-incrementing id, we just want to pass it + // the actual id in this case + intval($notification->id - 1) + ); + // self::$logger::warning(self::PREFIX . @var_export($event, true)); $connections[$notification->contact_id]->sendEvent( - new Event( - EventIdentifier::ICINGA2_NOTIFICATION, - (object) [ - 'incident_id' => $incident->incident_id, - 'event_id' => $incident->event_id, - 'host' => $host, - 'service' => $service, - 'time' => $time, - 'severity' => $incident->incident->severity - ], - // minus one as it's usually expected as an auto-incrementing id, we just want to pass it - // the actual id in this case - intval($notification->id - 1) - ) + $event ); ++$numOfNotifications; } diff --git a/library/Notifications/Daemon/Server.php b/library/Notifications/Daemon/Server.php index 7d8db3361..e5f4fd4b5 100644 --- a/library/Notifications/Daemon/Server.php +++ b/library/Notifications/Daemon/Server.php @@ -311,6 +311,7 @@ private function handleRequest(ServerRequestInterface $request): Response return new Response( StatusCodeInterface::STATUS_OK, [ + "Connection" => "keep-alive", "Content-Type" => "text/event-stream; charset=utf-8", "Cache-Control" => "no-cache", "X-Accel-Buffering" => "no" diff --git a/public/js/icinga-notifications-worker.js b/public/js/icinga-notifications-worker.js deleted file mode 120000 index 8eff9500d..000000000 --- a/public/js/icinga-notifications-worker.js +++ /dev/null @@ -1 +0,0 @@ -../../dist/serviceworker/build/icinga-notifications-worker.js \ No newline at end of file diff --git a/public/js/icinga-notifications-worker.js.map b/public/js/icinga-notifications-worker.js.map deleted file mode 120000 index 4f9a891c1..000000000 --- a/public/js/icinga-notifications-worker.js.map +++ /dev/null @@ -1 +0,0 @@ -../../dist/serviceworker/build/icinga-notifications-worker.js.map \ No newline at end of file diff --git a/public/js/notification.js b/public/js/notification.js deleted file mode 100644 index 05ea77e98..000000000 --- a/public/js/notification.js +++ /dev/null @@ -1,109 +0,0 @@ -(function (Icinga) { - - 'use strict'; - - Icinga.Behaviors = Icinga.Behaviors || {}; - - class Notification extends Icinga.EventListener { - constructor(icinga) { - super(icinga); - - this._icinga = icinga; - this._logger = icinga.logger; - this._activated = false; - this._eventSource = null; - this._init(); - } - - _init() { - this.on('rendered', '#main > #col1.container', this.onRendered, this); - window.addEventListener('beforeunload', this.onUnload); - window.addEventListener('unload', this.onUnload); - - console.log("loaded notifications.js"); - } - - _checkCompatibility() { - let isCompatible = true; - if (!('Notification' in window)) { - console.error("This webbrowser does not support the Notification API."); - isCompatible = false; - } - if (!('serviceWorker' in window.navigator)) { - console.error("This webbrowser does not support the ServiceWorker API."); - isCompatible = false; - } - return isCompatible; - } - - _checkPermissions() { - return ('Notification' in window) && (window.Notification.permission === 'granted'); - } - - onRendered(event) { - let _this = event.data.self; - // only process main event (not the bubbled triggers) - if (event.target === event.currentTarget && _this._activated === false) { - if (_this._checkCompatibility()) { - if (_this._checkPermissions() === false) { - // permissions are not granted, requesting them - window.Notification.requestPermission().then((permission) => { - if (permission !== 'granted') { - console.error("Notifications were requested but not granted. Skipping 'notification' workflow.") - } - }); - } - // register service worker (if not already registered) - try { - navigator.serviceWorker.register( - 'icinga-notifications-worker.js', - { - scope: '/icingaweb2/', - type: 'classic' - } - ).then((registration) => { - if (registration.installing) { - console.log("Service worker is installing."); - } else if (registration.waiting) { - console.log("Service worker has been installed and is waiting to be run."); - } else if (registration.active) { - console.log("Service worker has been activated."); - } - - if (navigator.serviceWorker.controller === null) { - /** - * hard refresh detected. This causes the browser to not forward fetch requests to - * service workers. Reloading the site fixes this. - */ - setTimeout(() => { - console.log("Hard refresh detected. Reloading page to fix the service workers."); - location.reload(); - }, 1000); - return; - } - - // connect to the daemon endpoint (should be intercepted by the service worker) - setTimeout(() => { - _this._eventSource = new EventSource('/icingaweb2/notifications/daemon'); - _this._activated = true; - }, 2500); - }); - } catch (error) { - console.error(`Service worker failed to register: ${error}`); - } - } else { - // unsupported in this browser, set activation to null - console.error("This browser doesn't support the needed APIs for desktop notifications."); - _this._activated = null; - } - } - } - - onUnload(event) { - // Icinga 2 module is going to unload, cleaning up notification handling - // console.log("onUnload triggered with", event); - } - } - - Icinga.Behaviors.Notification = Notification; -})(Icinga); diff --git a/public/js/notifications-worker.js b/public/js/notifications-worker.js new file mode 100644 index 000000000..b891ad9ca --- /dev/null +++ b/public/js/notifications-worker.js @@ -0,0 +1,106 @@ +const _PREFIX = '[notifications-worker] - '; +const _SERVER_CONNECTIONS = []; + +if (!(self instanceof ServiceWorkerGlobalScope)) { + throw new Error("Tried loading 'notification-worker.js' in a context other than a Service Worker."); +} + +/** @type {ServiceWorkerGlobalScope} */ +const selfSW = self; +selfSW.addEventListener('message', (event) => { + self.console.log(_PREFIX + "received a message: ", event); + this.processMessage(event); +}); +selfSW.addEventListener('activate', (event) => { + // claim all clients under own scope once the service worker gets activated + event.waitUntil( + selfSW.clients.claim().then(() => { + self.console.log(_PREFIX + "claimed all tabs."); + }) + ); +}); +selfSW.addEventListener('fetch', (event) => { + // self.console.log(_PREFIX + 'fetch event triggered with: ', event); + const request = event.request; + const url = new URL(event.request.url); + + // only check dedicated event stream requests towards the daemon + if (request.headers.get('accept').startsWith('text/event-stream') && url.pathname.trim() === '/icingaweb2/notifications/daemon') { + self.console.log(_PREFIX + `tab '${event.clientId}' requested event-stream.`); + event.respondWith(this.injectMiddleware(request, event.clientId)); + } +}); + +function processMessage(event) { + if (event.data) { + let data = JSON.parse(event.data); + switch (data.command) { + case 'tab_force_reclaim': + /* + * trigger the claim process as there seems to be new clients in our scope which aren't under our + * control + */ + self.clients.claim().then(() => { + self.console.log(_PREFIX + "reclaimed all tabs."); + }); + break; + } + } +} + +async function injectMiddleware(request, clientId) { + let response = await fetch(request, { + keepalive: true + }); + if (response.ok && response.body instanceof ReadableStream) { + self.console.log(_PREFIX + `injecting into data stream of tab '${clientId}'.`); + + const controllers = { + writable: undefined, + readable: undefined, + signal: new AbortController() + }; + let readStream = new ReadableStream({ + start(controller) { + controllers.readable = controller; + }, + cancel(reason) { + self.console.log(_PREFIX + `tab '${clientId}' closed event-stream (client-side).`); + // tab crashed or closed down connection to event-stream, stopping pipe through stream by + // triggering the abort signal + controllers.signal.abort(); + } + }, new CountQueuingStrategy({ highWaterMark: 10 })); + let writeStream = new WritableStream({ + start(controller) { + controllers.writable = controller; + }, + write(chunk, controller) { + controllers.readable.enqueue(chunk); + }, + close() { + // close was triggered by the server closing down the event-stream + self.console.log(_PREFIX + `tab '${clientId}' closed event-stream (server-side).`); + controllers.readable.close(); + }, + abort(reason) { + // close was triggered by an abort signal (most likely by the reader / client-side) + self.console.log(_PREFIX + `tab '${clientId}' closed event-stream (server-side).`); + controllers.readable.close(); + } + }, new CountQueuingStrategy({ highWaterMark: 10 })); + // returning injected (piped) stream + return new Response( + response.body.pipeThrough({ + writable: writeStream, + readable: readStream + }, { signal: controllers.signal.signal }), + { + headers: response.headers, + statusText: response.statusText, + status: response.status + } + ) + } + return response; +} diff --git a/public/js/notifications.js b/public/js/notifications.js new file mode 100644 index 000000000..007baccb2 --- /dev/null +++ b/public/js/notifications.js @@ -0,0 +1,198 @@ +(function (Icinga) { + + 'use strict'; + + Icinga.Behaviors = Icinga.Behaviors || {}; + + class Notification extends Icinga.EventListener { + _prefix = '[Notification] - '; + _jooat = function jooat(input) { + // jenkins one at a time, rewritten from http://www.burtleburtle.net/bob/hash/doobs.html#one + let hash, i; + + for (hash = i = 0; i < input.length; ++i) { + hash += input.charCodeAt(i); + hash += (hash << 10); + hash ^= (hash >>> 6); + } + hash += (hash << 3); + hash ^= (hash >>> 11); + hash += (hash << 15); + + return hash >>> 0; + } + + constructor(icinga) { + super(icinga); + + // only allow to be instantiated in a web context + if (!(self instanceof Window)) { + this._logger.error(this._prefix + "module should not get loaded outside of a web context!"); + throw new Error("Attempted to initialize the 'Notification' module outside of a web context!"); + } + + // initialize object fields + this._icinga = icinga; + this._logger = icinga.logger; + // TODO: Remove once done testing + this._logger.logLevel = 'debug'; + + // check for required API's + this._logger.debug(this._prefix + "checking for the required APIs and permissions."); + + let isValidated = true; + if(!('ServiceWorker' in self)) { + this._logger.error(this._prefix + "this browser does not support the 'Service Worker API' in the" + + " current context."); + isValidated = false; + } + if(!('Navigator' in self)) { + this._logger.error(this._prefix + "this browser does not support the 'Notification API' in the" + + " current context."); + } + if(!isValidated) { + throw new Error("The 'Notification' module is missing some required API's."); + } + + this._logger.debug(this._prefix + "spawned."); + this._load(); + } + + _load() { + this._logger.debug(this._prefix + "loading."); + + // this.on('rendered', '#main > #col1.container', this.onRendered, this); + + // register service worker if not already registered + self.navigator.serviceWorker.getRegistration(icinga.config.baseUrl).then((registration) => { + if(registration === undefined) { + // no service worker registered yet, registering it + self.navigator.serviceWorker.register(icinga.config.baseUrl + '/notifications-worker.js', { + scope: icinga.config.baseUrl + '/', + type: 'classic' + }).then((registration) => { + let claim_once_activated = (event) => { + if (event.target.state === 'activated') { + registration.active.postMessage( + JSON.stringify({ + command: 'tab_force_reclaim' + }) + ); + // remove this event listener again as we don't need it anymore + registration.active.removeEventListener('statechange', claim_once_activated); + } + }; + // listen to when the service worker gets activated + registration.installing.addEventListener('statechange', claim_once_activated); + }); + } + }); + + setTimeout(() => { + try { + self.console.log("Opening event source"); + let es = new EventSource(icinga.config.baseUrl + '/notifications/daemon', { withCredentials: true }); + es.addEventListener('message', (event) => { + self.console.log("Got message from event stream: ", event.data); + }); + es.addEventListener('error', (event) => { + self.console.error("Got an error: ", event); + }); + es.addEventListener('icinga2.notification', (event) => { + self.console.log("Got icinga2 notification: ", event.data); + }); + } catch (error) { + self.console.error("Got an error: ", error); + } + }, 5000); + + this._logger.debug(this._prefix + "loaded."); + } + + _unload() { + this._logger.debug(this._prefix + "unloading."); + + // disconnect EventSource if there's an active connection + if (this._eventSource && this._eventSource instanceof EventSource) { + if (this._eventSource.readyState !== EventSource.CLOSED) { + this._eventSource.close(); + } + } + this._eventSource = null; + + this._logger.debug(this._prefix + "unloaded."); + } + + _reload() { + this._unload(); + this._load(); + } + + _checkPermissions() { + return ('Notification' in window) && (window.Notification.permission === 'granted'); + } + + onRendered(event) { + return; + let _this = event.data.self; + // only process main event (not the bubbled triggers) + if (event.target === event.currentTarget && _this._running === false) { + // activating the module as we're not on the + if (_this._checkCompatibility()) { + if (_this._checkPermissions() === false) { + // permissions are not granted, requesting them + window.Notification.requestPermission().then((permission) => { + if (permission !== 'granted') { + console.error("Notifications were requested but not granted. Skipping 'notification' workflow.") + } + }); + } + // register service worker (if not already registered) + try { + navigator.serviceWorker.register( + 'icinga-notifications-worker.js', + { + scope: '/icingaweb2/', + type: 'classic' + } + ).then((registration) => { + if (registration.installing) { + console.log("Service worker is installing."); + } else if (registration.waiting) { + console.log("Service worker has been installed and is waiting to be run."); + } else if (registration.active) { + console.log("Service worker has been activated."); + } + + // if (navigator.serviceWorker.controller === null) { + // /** + // * hard refresh detected. This causes the browser to not forward fetch requests to + // * service workers. Reloading the site fixes this. + // */ + // setTimeout(() => { + // console.log("Hard refresh detected. Reloading page to fix the service workers."); + // location.reload(); + // }, 1000); + // return; + // } + + // connect to the daemon endpoint (should be intercepted by the service worker) + setTimeout(() => { + _this._eventSource = new EventSource('/icingaweb2/notifications/daemon'); + _this._activated = true; + }, 2500); + }); + } catch (error) { + console.error(`Service worker failed to register: ${error}`); + } + } else { + // unsupported in this browser, set activation to null + console.error("This browser doesn't support the needed APIs for desktop notifications."); + _this._activated = null; + } + } + } + } + + Icinga.Behaviors.Notification = Notification; +})(Icinga); diff --git a/run.php b/run.php index ad9495adb..13ceb4fa9 100644 --- a/run.php +++ b/run.php @@ -6,7 +6,7 @@ $this->provideHook('authentication', 'SessionStorage', true); $this->addRoute('static-file', new Zend_Controller_Router_Route_Regex( - 'icinga-notifications-(.[^.]*)(\..*)', + 'notifications-(.[^.]*)(\..*)', [ 'controller' => 'daemon', 'action' => 'script',