diff --git a/application/clicommands/DaemonCommand.php b/application/clicommands/DaemonCommand.php new file mode 100644 index 00000000..a7d0f386 --- /dev/null +++ b/application/clicommands/DaemonCommand.php @@ -0,0 +1,30 @@ +getHelper('viewRenderer'); + $viewRenderer->setNoRender(); + + /** @var Zend_Layout $layout */ + $layout = $this->getHelper('layout'); + $layout->disableLayout(); + } + + public function scriptAction(): void + { + /** + * we have to use `getRequest()->getParam` here instead of the usual `$this->param` as the required parameters + * are not submitted by an HTTP request but injected manually {@see icinga-notifications-web/run.php} + */ + $fileName = $this->getRequest()->getParam('file', 'undefined'); + $extension = $this->getRequest()->getParam('extension', 'undefined'); + $mime = ''; + + switch ($extension) { + case 'undefined': + $this->httpNotFound(t("File extension is missing.")); + + // no return + case '.js': + $mime = 'application/javascript'; + + break; + case '.js.map': + $mime = 'application/json'; + + break; + } + + $root = Icinga::app() + ->getModuleManager() + ->getModule('notifications') + ->getBaseDir() . '/public/js'; + + $filePath = realpath($root . DIRECTORY_SEPARATOR . 'notifications-' . $fileName . $extension); + if ($filePath === false || substr($filePath, 0, strlen($root)) !== $root) { + if ($fileName === 'undefined') { + $this->httpNotFound(t("No file name submitted")); + } + + $this->httpNotFound(sprintf(t("notifications-%s%s does not exist"), $fileName, $extension)); + } else { + $fileStat = stat($filePath); + + if ($fileStat) { + $eTag = sprintf( + '%x-%x-%x', + $fileStat['ino'], + $fileStat['size'], + (float) str_pad((string) ($fileStat['mtime']), 16, '0') + ); + + $this->getResponse()->setHeader( + 'Cache-Control', + 'public, max-age=1814400, stale-while-revalidate=604800', + true + ); + + if ($this->getRequest()->getServer('HTTP_IF_NONE_MATCH') === $eTag) { + $this->getResponse()->setHttpResponseCode(304); + } else { + $this->getResponse() + ->setHeader('ETag', $eTag) + ->setHeader('Content-Type', $mime, true) + ->setHeader( + 'Last-Modified', + gmdate('D, d M Y H:i:s', $fileStat['mtime']) . ' GMT' + ); + $file = file_get_contents($filePath); + if ($file) { + $this->getResponse()->setBody($file); + } + } + } else { + $this->httpNotFound(sprintf(t("notifications-%s%s could not be read"), $fileName, $extension)); + } + } + } +} diff --git a/config/systemd/icinga-notifications-web.service b/config/systemd/icinga-notifications-web.service new file mode 100644 index 00000000..2f6e2b8c --- /dev/null +++ b/config/systemd/icinga-notifications-web.service @@ -0,0 +1,10 @@ +[Unit] +Description=Icinga Notifications Background Daemon + +[Service] +Type=simple +ExecStart=/usr/bin/icingacli notifications daemon run +Restart=on-success + +[Install] +WantedBy=multi-user.target diff --git a/configuration.php b/configuration.php index 296936c8..5d098aeb 100644 --- a/configuration.php +++ b/configuration.php @@ -2,7 +2,9 @@ /* Icinga Notifications Web | (c) 2023 Icinga GmbH | GPLv2 */ -/** @var \Icinga\Application\Modules\Module $this */ +use Icinga\Application\Modules\Module; + +/** @var Module $this */ $section = $this->menuSection( N_('Notifications'), @@ -92,3 +94,5 @@ foreach ($cssFiles as $path) { $this->provideCssFile(ltrim(substr($path, strlen($cssDirectory)), DIRECTORY_SEPARATOR)); } + +$this->provideJsFile('notifications.js'); diff --git a/doc/06-Desktop-Notifications.md b/doc/06-Desktop-Notifications.md new file mode 100644 index 00000000..1ecb8f5b --- /dev/null +++ b/doc/06-Desktop-Notifications.md @@ -0,0 +1,106 @@ +# Desktop Notifications + +With Icinga Notifications, users are able to enable desktop notifications which will inform them about severity +changes in incidents they are notified about. + +> **Note** +> +> This feature is currently considered experimental and might not work as expected in all cases. +> We will continue to improve this feature in the future. Your feedback is highly appreciated. + +## How It Works + +A user can enable this feature in their account preferences, in case Icinga Web is being accessed by using a secure +connection. Once enabled, the web interface will establish a persistent connection to the web server which will push +notifications to the user's browser. This connection is only established when the user is logged in and has the web +interface open. This means that if the browser is closed, no notifications will be shown. + +For this reason, desktop notifications are not meant to be a primary notification method. This is also the reason +why they will only show up for incidents a contact is notified about by other means, e.g. email. + +In order to link a contact to the currently logged-in user, both the contact's and the user's username must match. + +### Supported Browsers + +All browsers [supported by Icinga Web](https://icinga.com/docs/icinga-web/latest/doc/02-Installation/#browser-support) +can be used to receive desktop notifications. Though, most mobile browsers are excluded, due to their aggressive energy +saving mechanisms. + +## Setup + +To get this to work, a background daemon needs to be accessible by HTTP through the same location as the web +interface. Each connection is long-lived as the daemon will push messages by using SSE (Server-Sent-Events) +to each connected client. + +### Configure The Daemon + +The daemon is configured in the `config.ini` file located in the module's configuration directory. The default +location is `/etc/icingaweb2/modules/notifications/config.ini`. + +In there, add a new section with the following content: + +```ini +[daemon] +host = [::] ; The IP address to listen on +port = 9001 ; The port to listen on +``` + +The values shown above are the default values. You can adjust them to your needs. + +### Configure The Webserver + +Since connection handling is performed by the background daemon itself, you need to configure your web server to +proxy requests to the daemon. The following examples show how to configure Apache and Nginx. They're based on the +default configuration Icinga Web ships with if you've used the `icingacli setup config webserver` command. + +Adjust the base URL `/icingaweb2` to your needs and the IP address and the port to what you have configured in the +daemon's configuration. + +**Apache** + +``` +\d+)/subscribe"> + SetEnvIf Authorization "(.*)" HTTP_AUTHORIZATION=$1 + RequestHeader set X-Icinga-Notifications-Protocol-Version %{MATCH_VERSION}e + ProxyPass http://127.0.0.1:9001 connectiontimeout=30 timeout=30 flushpackets=on + ProxyPassReverse http://127.0.0.1:9001 + +``` + +**Nginx** + +``` +location ~ ^/icingaweb2/notifications/v(\d+)/subscribe$ { + proxy_pass http://127.0.0.1:9001; + proxy_set_header Connection ""; + proxy_set_header X-Icinga-Notifications-Protocol-Version $1; + proxy_http_version 1.1; + proxy_buffering off; + proxy_cache off; + chunked_transfer_encoding off; +} +``` + +> **Note** +> +> Since these connections are long-lived, the default web server configuration might impose a too small limit on +> the maximum number of connections. Make sure to adjust this limit to a higher value. If working correctly, the +> daemon will limit the number of connections per client to 2. + +### Enable The Daemon + +The default `systemd` service, shipped with package installations, runs the background daemon. + + + +> **Note** +> +> If you haven't installed this module from packages, you have to configure this as a `systemd` service yourself by just +> copying the example service definition from `/usr/share/icingaweb2/modules/notifications/config/systemd/icinga-notifications-web.service` +> to `/etc/systemd/system/icinga-notifications-web.service`. + + +You can run the following command to enable and start the daemon. +``` +systemctl enable --now icinga-notifications-web.service +``` diff --git a/library/Notifications/Daemon/Daemon.php b/library/Notifications/Daemon/Daemon.php new file mode 100644 index 00000000..374c23be --- /dev/null +++ b/library/Notifications/Daemon/Daemon.php @@ -0,0 +1,343 @@ +load(); + } + + /** + * Return the singleton instance of the Daemon class + * + * @return Daemon Singleton instance + */ + public static function get(): Daemon + { + if (self::$instance === null) { + self::$instance = new Daemon(); + } + + return self::$instance; + } + + /** + * Run the loading logic + * + * @return void + */ + protected function load(): void + { + self::$logger::debug(self::PREFIX . "loading"); + + $this->loop = Loop::get(); + $this->signalHandling($this->loop); + $this->server = Server::get($this->loop); + $this->sender = Sender::get($this, $this->server); + $this->database = Database::get(); + + $this->database->connect(); + + $this->cancellationToken = false; + $this->initializedAt = time(); + + $this->run(); + + self::$logger::debug(self::PREFIX . "loaded"); + } + + /** + * Run the unloading logic + * + * @return void + */ + protected function unload(): void + { + self::$logger::debug(self::PREFIX . "unloading"); + + $this->cancellationToken = true; + + $this->database->disconnect(); + $this->server->unload(); + $this->sender->unload(); + $this->loop->stop(); + + unset($this->initializedAt); + unset($this->database); + unset($this->server); + unset($this->sender); + unset($this->loop); + + self::$logger::debug(self::PREFIX . "unloaded"); + } + + /** + * Run the reloading logic + * + * @return void + */ + protected function reload(): void + { + self::$logger::debug(self::PREFIX . "reloading"); + + $this->unload(); + $this->load(); + + self::$logger::debug(self::PREFIX . "reloaded"); + } + + /** + * Unload the class object and exit the script + * + * @param bool $isManualShutdown manual trigger for the shutdown + * + * @return never-return + */ + protected function shutdown(bool $isManualShutdown = false) + { + self::$logger::info(self::PREFIX . "shutting down" . ($isManualShutdown ? " (manually triggered)" : "")); + + $initAt = $this->initializedAt; + $this->unload(); + + self::$logger::info(self::PREFIX . "exited after " . floor((time() - $initAt)) . " seconds"); + exit(0); + } + + /** + * (Re-)Attach to process exit signals and call the shutdown logic + * + * @param LoopInterface $loop ReactPHP's main loop + * + * @return void + */ + protected function signalHandling(LoopInterface $loop): void + { + $reloadFunc = function () { + $this->reload(); + }; + + $exitFunc = function () { + $this->shutdown(true); + }; + + // clear existing signal handlers + $loop->removeSignal(SIGHUP, $reloadFunc); + $loop->removeSignal(SIGINT, $exitFunc); + $loop->removeSignal(SIGQUIT, $exitFunc); + $loop->removeSignal(SIGTERM, $exitFunc); + + // add new signal handlers + $loop->addSignal(SIGHUP, $reloadFunc); + $loop->addSignal(SIGINT, $exitFunc); + $loop->addSignal(SIGQUIT, $exitFunc); + $loop->addSignal(SIGTERM, $exitFunc); + } + + /** + * Clean up old sessions in the database + * + * @return void + */ + protected function housekeeping(): void + { + self::$logger::debug(self::PREFIX . "running housekeeping job"); + + $staleBrowserSessions = BrowserSession::on(Database::get()) + ->filter(Filter::lessThan('authenticated_at', time() - 86400)); + $deletions = 0; + + /** @var BrowserSession $session */ + foreach ($staleBrowserSessions as $session) { + $this->database->delete('browser_session', ['php_session_id = ?' => $session->php_session_id]); + ++$deletions; + } + + if ($deletions > 0) { + self::$logger::info(self::PREFIX . "housekeeping cleaned " . $deletions . " stale browser sessions"); + } + + self::$logger::debug(self::PREFIX . "finished housekeeping job"); + } + + /** + * Process new notifications (if there are any) + * + * @return void + */ + protected function processNotifications(): void + { + $numOfNotifications = 0; + + if ($this->lastIncidentId === null) { + // get the newest incident identifier + /** @var IncidentHistory $latestIncidentNotification */ + $latestIncidentNotification = IncidentHistory::on(Database::get()) + ->filter(Filter::equal('type', 'notified')) + ->orderBy('id', 'DESC') + ->first(); + if (! $latestIncidentNotification) { + // early return as we don't need to check for new entries if we don't have any at all + return; + } + + $this->lastIncidentId = $latestIncidentNotification->id; + self::$logger::debug( + self::PREFIX + . "fetched latest incident notification identifier: lastIncidentId + . ">" + ); + } + + // grab new notifications and the current connections + $notifications = IncidentHistory::on(Database::get()) + ->with(['event', 'incident', 'incident.object', 'incident.object.source']) + ->withColumns(['incident.object.id_tags']) + ->filter(Filter::greaterThan('id', $this->lastIncidentId)) + ->filter(Filter::equal('type', 'notified')) + ->filter(Filter::equal('notification_state', 'sent')) + ->orderBy('id', 'ASC'); + /** @var array> $connections */ + $connections = $this->server->getMatchedConnections(); + + /** @var IncidentHistory $notification */ + $notificationsToProcess = []; + foreach ($notifications as $notification) { + if (isset($connections[$notification->contact_id])) { + ObjectsRendererHook::register($notification->incident->object); + $notificationsToProcess[] = $notification; + + ++$numOfNotifications; + } + + $this->lastIncidentId = $notification->id; + } + + if ($numOfNotifications > 0) { + ObjectsRendererHook::load(false); + + foreach ($notificationsToProcess as $notification) { + /** @var Incident $incident */ + $incident = $notification->incident; + + $this->emit(EventIdentifier::ICINGA2_NOTIFICATION, [ + new Event( + EventIdentifier::ICINGA2_NOTIFICATION, + $notification->contact_id, + (object) [ + 'incident_id' => $notification->incident_id, + 'event_id' => $notification->event_id, + 'severity' => $incident->severity, + 'title' => ObjectsRendererHook::getObjectNameAsString($incident->object), + 'message' => $notification->event->message + ] + ) + ]); + } + } + + if ($numOfNotifications > 0) { + self::$logger::debug(self::PREFIX . "sent " . $numOfNotifications . " notifications"); + } + } + + /** + * Run main logic + * + * This method registers the needed Daemon routines on PhpReact's {@link Loop main loop}. + * It adds a cancellable infinite loop, which processes new database entries (notifications) every 3 seconds. + * In addition, a cleanup routine gets registered, which cleans up stale browser sessions each hour if they are + * older than a day. + * + * @return void + */ + protected function run(): void + { + $this->loop->futureTick(function () { + while ($this->cancellationToken === false) { + $beginMs = (int) (microtime(true) * 1000); + + self::$logger::debug(self::PREFIX . "ticking at " . time()); + $this->processNotifications(); + + $endMs = (int) (microtime(true) * 1000); + if (($endMs - $beginMs) < 3000) { + // run took less than 3 seconds; sleep for the remaining duration to prevent heavy db loads + await(sleep((3000 - ($endMs - $beginMs)) / 1000)); + } + } + self::$logger::debug(self::PREFIX . "cancellation triggered; exiting loop"); + $this->shutdown(); + }); + + // run housekeeping job every hour + $this->loop->addPeriodicTimer(3600.0, function () { + $this->housekeeping(); + }); + // run housekeeping once on daemon start + $this->loop->futureTick(function () { + $this->housekeeping(); + }); + } +} diff --git a/library/Notifications/Daemon/Sender.php b/library/Notifications/Daemon/Sender.php new file mode 100644 index 00000000..3b695c53 --- /dev/null +++ b/library/Notifications/Daemon/Sender.php @@ -0,0 +1,158 @@ +callback = function ($event) { + $this->processNotification($event); + }; + + $this->load(); + } + + /** + * Return the singleton instance of the Daemon class + * + * @param Daemon $daemon Reference to the Daemon instance + * @param Server $server Reference to the Server instance + * + * @return Sender Singleton instance + */ + public static function get(Daemon &$daemon, Server &$server): Sender + { + if (self::$instance === null) { + self::$instance = new Sender($daemon, $server); + } + + return self::$instance; + } + + /** + * Run the loading logic + * + * @return void + */ + public function load(): void + { + self::$logger::debug(self::PREFIX . "loading"); + + self::$daemon->on(EventIdentifier::ICINGA2_NOTIFICATION, $this->callback); + + self::$logger::debug(self::PREFIX . "loaded"); + } + + /** + * Run the unloading logic + * + * @return void + */ + public function unload(): void + { + self::$logger::debug(self::PREFIX . "unloading"); + + self::$daemon->removeListener(EventIdentifier::ICINGA2_NOTIFICATION, $this->callback); + + self::$logger::debug(self::PREFIX . "unloaded"); + } + + /** + * Run the reloading logic + * + * @return void + */ + public function reload(): void + { + self::$logger::debug(self::PREFIX . "reloading"); + + $this->unload(); + $this->load(); + + self::$logger::debug(self::PREFIX . "reloaded"); + } + + /** + * Process the given notification and send it to the appropriate clients + * + * @param Event $event Notification event + */ + protected function processNotification(Event $event): void + { + $connections = self::$server->getMatchedConnections(); + + // get contact's current connections + if (array_key_exists($event->getContact(), $connections)) { + $browserConnections = $connections[$event->getContact()]; + $notifiedBrowsers = []; + foreach ($browserConnections as $browserConnection) { + if (in_array($browserConnection->getUserAgent(), $notifiedBrowsers) === false) { + // this browser has not been notified yet + if ($browserConnection->sendEvent($event) === false) { + // writing to the browser stream failed, searching for a fallback connection for this browser + $fallback = false; + foreach ($browserConnections as $c) { + if ( + $c->getUserAgent() === $browserConnection->getUserAgent() + && $c !== $browserConnection + && $c->sendEvent($event) + ) { + // fallback connection for this browser exists and the notification delivery succeeded + $fallback = true; + + break; + } + } + + if ($fallback === false) { + self::$logger::error( + self::PREFIX + . "failed sending event '" . $event->getIdentifier() + . "' to <" . $browserConnection->getAddress() . ">" + ); + } + } + + $notifiedBrowsers[] = $browserConnection->getUserAgent(); + } + } + } + } +} diff --git a/library/Notifications/Daemon/Server.php b/library/Notifications/Daemon/Server.php new file mode 100644 index 00000000..f3716d3e --- /dev/null +++ b/library/Notifications/Daemon/Server.php @@ -0,0 +1,502 @@ + Socket connections */ + protected $connections; + + /** @var SQLConnection Database object */ + protected $dbLink; + + /** @var Config Config object */ + protected $config; + + /** + * Construct the singleton instance of the Server class + * + * @param LoopInterface $mainLoop Reference to ReactPHP's main loop + */ + private function __construct(LoopInterface &$mainLoop) + { + self::$logger = Logger::getInstance(); + self::$logger::debug(self::PREFIX . "spawned"); + + $this->mainLoop = &$mainLoop; + $this->dbLink = Database::get(); + $this->config = Config::module('notifications'); + + $this->load(); + } + + /** + * Return the singleton instance of the Server class + * + * @param LoopInterface $mainLoop Reference to ReactPHP's main loop + * + * @return Server Singleton instance + */ + public static function get(LoopInterface &$mainLoop): Server + { + if (self::$instance === null) { + self::$instance = new Server($mainLoop); + } elseif ((self::$instance->mainLoop !== null) && (self::$instance->mainLoop !== $mainLoop)) { + // main loop changed, reloading daemon server + self::$instance->mainLoop = $mainLoop; + self::$instance->reload(); + } + + return self::$instance; + } + + /** + * Run the loading logic + * + * @return void + */ + public function load(): void + { + self::$logger::debug(self::PREFIX . "loading"); + + $this->connections = []; + $this->socket = new SocketServer( + $this->config->get('daemon', 'host', '[::]') + . ':' + . $this->config->get('daemon', 'port', '9001'), + [], + $this->mainLoop + ); + $this->http = new HttpServer(function (ServerRequestInterface $request) { + return $this->handleRequest($request); + }); + $this->http->on('error', function (Throwable $error) { + self::$logger::error(self::PREFIX . "received an error on the http server: %s", $error); + }); + // subscribe to socket events + $this->socket->on('connection', function (ConnectionInterface $connection) { + $this->onSocketConnection($connection); + }); + $this->socket->on('error', function (Throwable $error) { + self::$logger::error(self::PREFIX . "received an error on the socket: %s", $error); + }); + // attach http server to socket + $this->http->listen($this->socket); + + self::$logger::info( + self::PREFIX + . "listening on " + . parse_url($this->socket->getAddress() ?? '', PHP_URL_HOST) + . ':' + . parse_url($this->socket->getAddress() ?? '', PHP_URL_PORT) + ); + + // add keepalive routine to prevent connection aborts by proxies (Nginx, Apache) or browser restrictions (like + // the Fetch API on Mozilla Firefox) + // https://html.spec.whatwg.org/multipage/server-sent-events.html#authoring-notes + $this->mainLoop->addPeriodicTimer(30.0, function () { + $this->keepalive(); + }); + + self::$logger::debug(self::PREFIX . "loaded"); + } + + /** + * Run the unloading logic + * + * @return void + */ + public function unload(): void + { + self::$logger::debug(self::PREFIX . "unloading"); + + $this->socket->close(); + + unset($this->http); + unset($this->socket); + unset($this->connections); + + self::$logger::debug(self::PREFIX . "unloaded"); + } + + /** + * Run the reloading logic + * + * @return void + */ + public function reload(): void + { + self::$logger::debug(self::PREFIX . "reloading"); + + $this->unload(); + $this->load(); + + self::$logger::debug(self::PREFIX . "reloaded"); + } + + /** + * Map an HTTP(S) request to an already existing socket connection (TCP) + * + * @param ServerRequestInterface $request Request to be mapped against a socket connection + * + * @return ?Connection Connection object or null if no connection could be mapped against the request + */ + protected function mapRequestToConnection(ServerRequestInterface $request): ?Connection + { + $params = $request->getServerParams(); + $scheme = $request->getUri()->getScheme(); + + if (isset($params['REMOTE_ADDR']) && isset($params['REMOTE_PORT'])) { + $address = Connection::parseHostAndPort( + $scheme . '://' . $params['REMOTE_ADDR'] . ':' . $params['REMOTE_PORT'] + ); + foreach ($this->connections as $connection) { + if ($connection->getAddress() === $address->addr) { + return $connection; + } + } + } + + return null; + } + + /** + * Emit method for socket connections events + * + * @param ConnectionInterface $connection Connection details + * + * @return void + */ + protected function onSocketConnection(ConnectionInterface $connection): void + { + if ($connection->getRemoteAddress() !== null) { + $address = Connection::parseHostAndPort($connection->getRemoteAddress()); + + // subscribe to events on this connection + $connection->on('close', function () use ($connection) { + $this->onSocketConnectionClose($connection); + }); + + // keep track of this connection + self::$logger::debug(self::PREFIX . "<" . $address->addr . "> adding connection to connection pool"); + $this->connections[$address->addr] = new Connection($connection); + } else { + self::$logger::warning(self::PREFIX . "failed adding connection as the remote address was empty"); + } + } + + + /** + * Emit method for socket connection close events + * + * @param ConnectionInterface $connection Connection details + * + * @return void + */ + protected function onSocketConnectionClose(ConnectionInterface $connection): void + { + // delete the reference to this connection if we have been actively tracking it + if ($connection->getRemoteAddress() !== null) { + $address = Connection::parseHostAndPort($connection->getRemoteAddress()); + if (isset($this->connections[$address->addr])) { + self::$logger::debug( + self::PREFIX . "<" . $address->addr . "> removing connection from connection pool" + ); + unset($this->connections[$address->addr]); + } + } else { + self::$logger::warning(self::PREFIX . "failed removing connection as the remote address was empty"); + } + } + + /** + * Handle the request and return an event-stream if the authentication succeeds + * + * @param ServerRequestInterface $request Request to be processed + * + * @return Response HTTP response (event-stream on success, status 204/500 otherwise) + */ + protected function handleRequest(ServerRequestInterface $request): Response + { + // try to map the request to a socket connection + $connection = $this->mapRequestToConnection($request); + if ($connection === null) { + $params = $request->getServerParams(); + $address = (object) array( + 'host' => '', + 'port' => '', + 'addr' => '' + ); + if (isset($params['REMOTE_ADDR']) && isset($params['REMOTE_PORT'])) { + $address = Connection::parseHostAndPort($params['REMOTE_ADDR'] . ':' . $params['REMOTE_PORT']); + } + + self::$logger::warning( + self::PREFIX + . ($address->addr !== '' ? ("<" . $address->addr . "> ") : '') + . "failed matching HTTP request to a tracked connection" + ); + return new Response( + StatusCodeInterface::STATUS_INTERNAL_SERVER_ERROR, + [ + "Content-Type" => "text/plain", + "Cache-Control" => "no-cache" + ], + '' + ); + } + + $version = $request->getHeader('X-Icinga-Notifications-Protocol-Version')[0] ?? 1; + self::$logger::debug( + self::PREFIX + . "<" + . $connection->getAddress() + . "> received a request with protocol version " + . $version + ); + + // request is mapped to an active socket connection; try to authenticate the request + $authData = $this->authenticate($connection, $request->getCookieParams(), $request->getHeaders()); + if (isset($authData->isValid) && $authData->isValid === false) { + // authentication failed + self::$logger::warning( + self::PREFIX . "<" . $connection->getAddress() . "> failed the authentication. Denying the request" + ); + return new Response( + // returning 204 to stop the service-worker from reconnecting + // see https://javascript.info/server-sent-events#reconnection + StatusCodeInterface::STATUS_NO_CONTENT, + [ + "Content-Type" => "text/plain", + "Cache-Control" => "no-cache" + ], + '' + ); + } + + self::$logger::debug(self::PREFIX . "<" . $connection->getAddress() . "> succeeded the authentication"); + + // try to match the authenticated connection to a notification contact + $contactId = $this->matchContact($connection->getUser()->getUsername()); + if ($contactId === null) { + self::$logger::warning( + self::PREFIX + . "<" + . $connection->getAddress() + . "> could not match user " + . $connection->getUser()->getUsername() + . " to an existing notification contact. Denying the request" + ); + return new Response( + // returning 204 to stop the service-worker from reconnecting + // see https://javascript.info/server-sent-events#reconnection + StatusCodeInterface::STATUS_NO_CONTENT, + [ + "Content-Type" => "text/plain", + "Cache-Control" => "no-cache" + ], + '' + ); + } + + // save matched contact identifier to user + $connection->getUser()->setContactId($contactId); + self::$logger::debug( + self::PREFIX + . "<" + . $connection->getAddress() + . "> matched connection to contact " + . $connection->getUser()->getUsername() + . " getUser()->getContactId() + . ">" + ); + + // request is valid and matching, returning the corresponding event stream + self::$logger::info( + self::PREFIX + . "<" + . $connection->getAddress() + . "> request is authenticated and matches a proper notification user" + ); + + // schedule initial keep-alive + $this->mainLoop->addTimer(1.0, function () use ($connection) { + $connection->getStream()->write(':' . PHP_EOL . PHP_EOL); + }); + + // return stream + 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" + ], + $connection->getStream() + ); + } + + /** + * @param Connection $connection + * @param array $cookies + * @param array> $headers + * + * @return object{isValid: bool, php_session_id: ?string, user: ?string, user_agent: ?string} + */ + protected function authenticate(Connection $connection, array $cookies, array $headers): object + { + $data = (object) [ + 'isValid' => false, + 'php_session_id' => null, + 'user' => null, + 'user_agent' => null + ]; + + if (! array_key_exists('Icingaweb2', $cookies)) { + // early return as the authentication needs the Icingaweb2 session token + return $data; + } + + // session id is supplied, check for the existence of a user-agent header as it's needed to calculate + // the browser id + if (array_key_exists('User-Agent', $headers) && sizeof($headers['User-Agent']) === 1) { + // grab session + /** @var BrowserSession $browserSession */ + $browserSession = BrowserSession::on($this->dbLink) + ->filter(Filter::equal('php_session_id', htmlspecialchars(trim($cookies['Icingaweb2'])))) + ->first(); + + if ($browserSession !== null) { + if (isset($headers['User-Agent'][0])) { + // limit user-agent to 4k chars + $userAgent = substr(trim($headers['User-Agent'][0]), 0, 4096); + } else { + $userAgent = 'default'; + } + + // check if user agent of connection corresponds to user agent of authenticated session + if ($userAgent === $browserSession->user_agent) { + // making sure that it's the latest browser session + /** @var BrowserSession $latestSession */ + $latestSession = BrowserSession::on($this->dbLink) + ->filter(Filter::equal('username', $browserSession->username)) + ->filter(Filter::equal('user_agent', $browserSession->user_agent)) + ->orderBy('authenticated_at', 'DESC') + ->first(); + if (isset($latestSession) && ($latestSession->php_session_id === $browserSession->php_session_id)) { + // current browser session is the latest session for this user and browser => a valid request + $data->php_session_id = $browserSession->php_session_id; + $data->user = $browserSession->username; + $data->user_agent = $browserSession->user_agent; + $connection->setSession($data->php_session_id); + $connection->getUser()->setUsername($data->user); + $connection->setUserAgent($data->user_agent); + $data->isValid = true; + + return $data; + } + } + } + } + + // authentication failed + return $data; + } + + /** + * Send keepalive (empty event message) to all connected clients + * + * @return void + */ + protected function keepalive(): void + { + foreach ($this->connections as $connection) { + $connection->getStream()->write(':' . PHP_EOL . PHP_EOL); + } + } + + /** + * Match a username to a contact identifier + * + * @param ?string $username + * + * @return ?int contact identifier or null if no contact could be matched + */ + protected function matchContact(?string $username): ?int + { + /** + * TODO(nc): the matching needs to be properly rewritten once we decide about how we want to handle the contacts + * in the notifications module + */ + if ($username !== null) { + /** @var Contact $contact */ + $contact = Contact::on(Database::get()) + ->filter(Filter::equal('username', $username)) + ->first(); + if ($contact !== null) { + return $contact->id; + } + } + + return null; + } + + /** + * Return list of contacts and their current connections + * + * @return array> + */ + public function getMatchedConnections(): array + { + $connections = []; + foreach ($this->connections as $connection) { + $contactId = $connection->getUser()->getContactId(); + if (isset($contactId)) { + if (isset($connections[$contactId]) === false) { + $connections[$contactId] = []; + } + $connections[$contactId][] = $connection; + } + } + + return $connections; + } +} diff --git a/library/Notifications/Model/BrowserSession.php b/library/Notifications/Model/BrowserSession.php new file mode 100644 index 00000000..d901f85b --- /dev/null +++ b/library/Notifications/Model/BrowserSession.php @@ -0,0 +1,67 @@ + t('PHP\'s Session Identifier'), + 'username' => t('Username'), + 'user_agent' => t('User-Agent'), + 'authenticated_at' => t('Authenticated At') + ]; + } + + public function getSearchColumns(): array + { + return [ + 'php_session_id', + 'username', + 'user_agent' + ]; + } + + public function createBehaviors(Behaviors $behaviors): void + { + $behaviors->add(new MillisecondTimestamp(['authenticated_at'])); + } +} diff --git a/library/Notifications/Model/Contact.php b/library/Notifications/Model/Contact.php index 5db9ef41..539e1728 100644 --- a/library/Notifications/Model/Contact.php +++ b/library/Notifications/Model/Contact.php @@ -9,6 +9,9 @@ use ipl\Orm\Model; use ipl\Orm\Relations; +/** + * @property int $id + */ class Contact extends Model { public function getTableName(): string diff --git a/library/Notifications/Model/Daemon/Connection.php b/library/Notifications/Model/Daemon/Connection.php new file mode 100644 index 00000000..f2fb050a --- /dev/null +++ b/library/Notifications/Model/Daemon/Connection.php @@ -0,0 +1,167 @@ +connection = $connection; + $this->host = ''; + $this->port = -1; + + if ($connection->getRemoteAddress() !== null) { + $address = $this->parseHostAndPort($connection->getRemoteAddress()); + if ($address) { + $this->host = $address->host; + $this->port = (int) $address->port; + } + } + + $this->stream = new ThroughStream(); + $this->session = ''; + $this->user = new User(); + $this->userAgent = ''; + } + + public function getHost(): string + { + return $this->host; + } + + public function getPort(): int + { + return $this->port; + } + + public function getAddress(): string + { + return $this->host . ':' . $this->port; + } + + public function getSession(): ?string + { + return $this->session; + } + + public function getStream(): ThroughStream + { + return $this->stream; + } + + public function getConnection(): ConnectionInterface + { + return $this->connection; + } + + public function getUser(): User + { + return $this->user; + } + + public function getUserAgent(): ?string + { + return $this->userAgent; + } + + public function setSession(string $session): void + { + $this->session = $session; + } + + public function setUserAgent(string $userAgent): void + { + $this->userAgent = $userAgent; + } + + /** + * @param ?string $address Host address + * + * @return object{host: string, port: string, addr: string} | false Host, port and full address or false if the + * parsing failed + */ + public static function parseHostAndPort(?string $address) + { + if ($address === null) { + return false; + } + + $raw = $address; + $parsed = (object) [ + 'host' => '', + 'port' => '', + 'addr' => '' + ]; + + // host + $host = parse_url($raw, PHP_URL_HOST); + $port = parse_url($raw, PHP_URL_PORT); + + if (! $host || ! $port) { + return false; + } + + if (strpos($host, '[') !== false) { + // IPv6 format + if (strpos($host, '.')) { + // IPv4 represented in IPv6 + $offset = strrpos($host, ':'); + $parsed->host = substr($host, $offset === false ? 0 : $offset + 1, -1); + } else { + // it's a native IPv6 + $parsed->host = $host; + } + } else { + // IPv4 format + $parsed->host = $host; + } + + $parsed->port = $port; + $parsed->addr = $parsed->host . ':' . $parsed->port; + + return $parsed; + } + + /** + * Send an event to the connection + * + * @param Event $event Event + * + * @return bool if the event could be pushed to the connection stream + */ + public function sendEvent(Event $event): bool + { + return $this->stream->write($event); + } +} diff --git a/library/Notifications/Model/Daemon/Event.php b/library/Notifications/Model/Daemon/Event.php new file mode 100644 index 00000000..6339885e --- /dev/null +++ b/library/Notifications/Model/Daemon/Event.php @@ -0,0 +1,109 @@ +identifier = $identifier; + $this->contact = $contact; + $this->data = $data; + $this->reconnectInterval = 3000; + $this->lastEventId = $lastEventId; + + $this->createdAt = new DateTime(); + } + + public function getIdentifier(): string + { + return $this->identifier; + } + + public function getContact(): int + { + return $this->contact; + } + + public function getData(): stdClass + { + return $this->data; + } + + public function getCreatedAt(): string + { + return $this->createdAt->format(DateTimeInterface::RFC3339_EXTENDED); + } + + public function getReconnectInterval(): int + { + return $this->reconnectInterval; + } + + public function getLastEventId(): int + { + return $this->lastEventId; + } + + public function setReconnectInterval(int $reconnectInterval): void + { + $this->reconnectInterval = $reconnectInterval; + } + + /** + * Compile event message according to + * {@link https://html.spec.whatwg.org/multipage/server-sent-events.html#parsing-an-event-stream SSE Spec} + * + * @return string + * @throws JsonEncodeException + */ + protected function compileMessage(): string + { + $payload = (object) [ + 'time' => $this->getCreatedAt(), + 'payload' => $this->getData() + ]; + + $message = 'event: ' . $this->identifier . PHP_EOL; + $message .= 'data: ' . Json::encode($payload) . PHP_EOL; + //$message .= 'id: ' . ($this->getLastEventId() + 1) . PHP_EOL; + $message .= 'retry: ' . $this->reconnectInterval . PHP_EOL; + + // ending newline + $message .= PHP_EOL; + + return $message; + } + + public function __toString(): string + { + // compile event to the appropriate representation for event streams + return $this->compileMessage(); + } +} diff --git a/library/Notifications/Model/Daemon/EventIdentifier.php b/library/Notifications/Model/Daemon/EventIdentifier.php new file mode 100644 index 00000000..5ebde417 --- /dev/null +++ b/library/Notifications/Model/Daemon/EventIdentifier.php @@ -0,0 +1,16 @@ +username = null; + $this->contactId = null; + } + + public function getUsername(): ?string + { + return $this->username; + } + + public function getContactId(): ?int + { + return $this->contactId; + } + + public function setUsername(string $username): void + { + $this->username = $username; + } + + public function setContactId(int $contactId): void + { + $this->contactId = $contactId; + } +} diff --git a/library/Notifications/Model/IncidentHistory.php b/library/Notifications/Model/IncidentHistory.php index 3e19efc5..8cb4e0d6 100644 --- a/library/Notifications/Model/IncidentHistory.php +++ b/library/Notifications/Model/IncidentHistory.php @@ -5,11 +5,14 @@ namespace Icinga\Module\Notifications\Model; use DateTime; +use Icinga\Module\Notifications\Common\Database; use ipl\Orm\Behavior\MillisecondTimestamp; use ipl\Orm\Behaviors; use ipl\Orm\Model; use ipl\Orm\Query; use ipl\Orm\Relations; +use ipl\Sql\Connection; +use ipl\Sql\Select; /** * IncidentHistory @@ -105,6 +108,19 @@ public function getDefaultSort() return ['incident_history.time desc, incident_history.type desc']; } + public static function on(Connection $db) + { + $query = parent::on($db); + + $query->on(Query::ON_SELECT_ASSEMBLED, function (Select $select) use ($query) { + if (isset($query->getUtilize()['incident_history.incident.object.object_id_tag'])) { + Database::registerGroupBy($query, $select); + } + }); + + return $query; + } + public function createRelations(Relations $relations) { $relations->belongsTo('incident', Incident::class); diff --git a/library/Notifications/ProvidedHook/SessionStorage.php b/library/Notifications/ProvidedHook/SessionStorage.php new file mode 100644 index 00000000..87164fe4 --- /dev/null +++ b/library/Notifications/ProvidedHook/SessionStorage.php @@ -0,0 +1,159 @@ +session = Session::getSession(); + $this->database = Database::get(); + } + + public function onLogin(User $user): void + { + Logger::info('running onLogin hook'); + + if ($this->session->exists()) { + // user successfully authenticated + $rawUserAgent = (new UserAgent())->getAgent(); + if ($rawUserAgent) { + // limit user-agent to 4k chars + $userAgent = substr(trim($rawUserAgent), 0, 4096); + } else { + $userAgent = 'default'; + } + + // check if session with this identifier already exists (zombie session) + $zombieSession = BrowserSession::on(Database::get()) + ->filter(Filter::equal('php_session_id', $this->session->getId())) + ->first(); + + if ($zombieSession !== null) { + // session with same id exists + // cleaning up the old session from the database as this one just got authenticated + $this->database->beginTransaction(); + try { + $this->database->delete( + 'browser_session', + ['php_session_id = ?' => $this->session->getId()] + ); + $this->database->commitTransaction(); + } catch (PDOException $e) { + Logger::error( + "Failed deleting browser session from table 'browser_session': \n\t" . $e->getMessage() + ); + $this->database->rollBackTransaction(); + } + } + + // cleanup existing sessions from this user (only for the current browser) + $userSessions = BrowserSession::on(Database::get()) + ->filter(Filter::equal('username', trim($user->getUsername()))) + ->filter(Filter::equal('user_agent', $userAgent)) + ->execute(); + /** @var BrowserSession $session */ + foreach ($userSessions as $session) { + $this->database->delete( + 'browser_session', + [ + 'php_session_id = ?' => $session->php_session_id, + 'username = ?' => trim($user->getUsername()), + 'user_agent = ?' => $userAgent + ] + ); + } + + // add current session to the db + $this->database->beginTransaction(); + try { + $this->database->insert( + 'browser_session', + [ + 'php_session_id' => $this->session->getId(), + 'username' => trim($user->getUsername()), + 'user_agent' => $userAgent, + 'authenticated_at' => (new DateTime())->format('Uv') + ] + ); + $this->database->commitTransaction(); + } catch (PDOException $e) { + Logger::error( + "Failed adding browser session to table 'browser_session': \n\t" . $e->getMessage() + ); + $this->database->rollBackTransaction(); + } + + Logger::debug( + "onLogin triggered for user " + . trim($user->getUsername()) + . " and browser session " + . $this->session->getId() + ); + } + } + + public function onLogout(User $user): void + { + if ($this->session->exists()) { + // user disconnected, removing the session from the database (invalidating it) + if ($this->database->ping() === false) { + $this->database->connect(); + } + + $rawUserAgent = (new UserAgent())->getAgent(); + if ($rawUserAgent) { + // limit user-agent to 4k chars + $userAgent = substr(trim($rawUserAgent), 0, 4096); + } else { + $userAgent = 'default'; + } + + $this->database->beginTransaction(); + try { + $this->database->delete( + 'browser_session', + [ + 'php_session_id = ?' => $this->session->getId(), + 'username = ?' => trim($user->getUsername()), + 'user_agent = ?' => $userAgent + ] + ); + $this->database->commitTransaction(); + } catch (PDOException $e) { + Logger::error( + "Failed deleting browser session from table 'browser_session': \n\t" . $e->getMessage() + ); + $this->database->rollBackTransaction(); + } + + Logger::debug( + "onLogout triggered for user " + . trim($user->getUsername()) + . " and browser session " + . $this->session->getId() + ); + } + } +} diff --git a/public/img/icinga-notifications-critical.webp b/public/img/icinga-notifications-critical.webp new file mode 100644 index 00000000..2ff909d3 Binary files /dev/null and b/public/img/icinga-notifications-critical.webp differ diff --git a/public/img/icinga-notifications-ok.webp b/public/img/icinga-notifications-ok.webp new file mode 100644 index 00000000..c1fd49dc Binary files /dev/null and b/public/img/icinga-notifications-ok.webp differ diff --git a/public/img/icinga-notifications-unknown.webp b/public/img/icinga-notifications-unknown.webp new file mode 100644 index 00000000..64375bc4 Binary files /dev/null and b/public/img/icinga-notifications-unknown.webp differ diff --git a/public/img/icinga-notifications-warning.webp b/public/img/icinga-notifications-warning.webp new file mode 100644 index 00000000..206b063a Binary files /dev/null and b/public/img/icinga-notifications-warning.webp differ diff --git a/public/js/doc/NOTIFICATIONS.md b/public/js/doc/NOTIFICATIONS.md new file mode 100644 index 00000000..38bb9527 --- /dev/null +++ b/public/js/doc/NOTIFICATIONS.md @@ -0,0 +1,29 @@ +# Notifications Notifications (JS module, service worker) + +## Architecture + +The desktop notification feature interacts with a service worker. The following illustration shows the architectural +structure of the feature: + +architecture + +The individual browser tabs essentially send their requests through the service worker, which then decides whether to +block the request or if it should forward it to the daemon. +In case the request gets forwarded to the daemon, the service worker injects itself between the daemon and the +browser tab by piping the readable stream. It can thus react to stream abortions from both sides. + +## Why the stream injection? + +The service worker needs to be able to decide on whether to open up new event-streams or not. If Icinga 2 would only +target desktop devices, it could just use JavaScript's `beforeunload/unload` +events ([check this](https://www.igvita.com/2015/11/20/dont-lose-user-and-app-state-use-page-visibility/)). + +Mobile devices unfortunately behave a little different, and they might not trigger those events while putting the tab in +the background or while freezing it (battery saving features; happens after a while when the phone gets locked). + +The `visibilitychange` event on the other hand, works as intended - even on mobile devices. But it's pretty much +impossible for JavaScript to differentiate between a browser hiding a tab, a tab freeze (as the browser gets put into +the background) or a tab kill. + +As the browser should ideally be constantly connected to the daemon through two event-streams, the service worker +has to know when an event-stream closes down. diff --git a/public/js/doc/notifications-arch.puml b/public/js/doc/notifications-arch.puml new file mode 100644 index 00000000..d5d61dd7 --- /dev/null +++ b/public/js/doc/notifications-arch.puml @@ -0,0 +1,29 @@ +@startuml + +skinparam componentStyle rectangle + +package "Browser" as B { + [Tab ..] <--> [ServiceWorker] + [Tab y] <--> [ServiceWorker] + [Tab x] <--> [ServiceWorker] +} + +package "Server" as S { + [Daemon] +} + +[ServiceWorker] <.. [Daemon] : event-stream + +note left of S + The daemon communicates with the forwarded event-stream requests in an unidirectional way. +end note + +note as NB + Browser consists of n amount of tabs. + The service worker communicates with the tabs in a bidirectional way. + It also forwards event-stream request towards the daemon + (but limits it to two concurrent event-stream connections). +end note + +NB .. B +@enduml diff --git a/public/js/doc/notifications-arch.svg b/public/js/doc/notifications-arch.svg new file mode 100644 index 00000000..6fca0b2f --- /dev/null +++ b/public/js/doc/notifications-arch.svg @@ -0,0 +1,140 @@ + + + + + + + + + Browser + + + + + + Server + + + + + Tab .. + + + + + ServiceWorker + + + + + Tab y + + + + + Tab x + + + + + Daemon + + + + + + The daemon communicates with the forwarded event-stream requests in an unidirectional + way. + + + + + + Browser consists of + + n + + amount of tabs. + + The service worker communicates with the tabs in a bidirectional way. + + It also forwards event-stream request towards the daemon + + (but limits it to two concurrent event-stream connections). + + + + + + + + + + + + + + + + + + + + + event-stream + + + + + + + + + + diff --git a/public/js/module.js b/public/js/module.js index 50107167..f5fbaefb 100644 --- a/public/js/module.js +++ b/public/js/module.js @@ -1,4 +1,6 @@ -(function(Icinga) { +/* Icinga Notifications Web | (c) 2024 Icinga GmbH | GPLv2 */ + +(function (Icinga) { "use strict"; diff --git a/public/js/notifications-worker.js b/public/js/notifications-worker.js new file mode 100644 index 00000000..289cbbd7 --- /dev/null +++ b/public/js/notifications-worker.js @@ -0,0 +1,321 @@ +/* Icinga Notifications Web | (c) 2024 Icinga GmbH | GPLv2 */ + +const VERSION = { + WORKER: 1, + SCRIPT: 1 +}; + +const PREFIX = '[notifications-worker] - '; +const SERVER_CONNECTIONS = {}; + +self.console.log(PREFIX + `started worker on `); + +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) => { + processMessage(event); +}); +selfSW.addEventListener('activate', (event) => { + // claim all clients + event.waitUntil(selfSW.clients.claim()); +}); +selfSW.addEventListener('install', (event) => { + // TODO: has no effect in case of an active SSE connection + event.waitUntil(selfSW.skipWaiting()); +}); +selfSW.addEventListener('fetch', (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.match(/\/notifications\/v(\d+)\/subscribe$/) === null + ) { + return; + } + + if (Object.keys(SERVER_CONNECTIONS).length < 2) { + self.console.log(PREFIX + ` requested event-stream`); + event.respondWith(injectMiddleware(request, event.clientId)); + } else { + self.console.log( + PREFIX + + `event-stream request from got blocked as there's already 2 active connections` + ); + // block request as the event-stream unneeded for now (2 tabs are already connected) + event.respondWith(new Response( + null, + { + status: 204, + statusText: 'No Content' + } + )); + } +}); +selfSW.addEventListener('notificationclick', (event) => { + event.notification.close(); + if (! ('action' in event)) { + void self.clients.openWindow(event.notification.data.url); + } else { + switch (event.action) { + case 'viewIncident': + void self.clients.openWindow(event.notification.data.url); + break; + case 'dismiss': + break; + } + } +}); + +function processMessage(event) { + if (! event.data) { + return; + } + + let data = JSON.parse(event.data); + switch (data.command) { + case 'handshake': + if (data.version === VERSION.SCRIPT) { + self.console.log( + PREFIX + + `accepting handshake from ` + ); + event.source.postMessage( + JSON.stringify({ + command: 'handshake', + status: 'accepted' + }) + ); + } else { + self.console.log( + PREFIX + + `denying handshake from as it does not ` + + `run the desired version: ${VERSION.SCRIPT}` + ); + event.source.postMessage( + JSON.stringify({ + command: 'handshake', + status: 'outdated' + }) + ); + } + + break; + case 'notification': + /* + * displays a notification through the service worker (if the permissions have been granted) + */ + if (('Notification' in self) && (self.Notification.permission === 'granted')) { + const notification = data.notification; + const title = notification.payload.title; + const message = notification.payload.message; + let severity = 'unknown'; + + // match severity + switch (notification.payload.severity) { + case 'ok': + severity = 'ok'; + break; + case 'warning': + severity = 'warning'; + break; + case 'crit': + severity = 'critical'; + break; + } + + void self.registration.showNotification( + title, + { + icon: data.baseUrl + '/img/notifications/icinga-notifications-' + severity + '.webp', + body: message, + data: { + url: + data.baseUrl + + '/notifications/incident?id=' + + notification.payload.incident_id + }, + actions: [ + { + action: 'viewIncident', title: 'View incident' + }, + { + action: 'dismiss', title: 'Dismiss' + } + ] + } + ); + } + + break; + case 'storage_toggle_update': + if (data.state) { + // notifications got enabled + // ask clients to open up stream + self.clients + .matchAll({type: 'window', includeUncontrolled: false}) + .then((clients) => { + let clientsToOpen = 2 - (Object.keys(SERVER_CONNECTIONS).length); + if (clientsToOpen > 0) { + for (const client of clients) { + if (clientsToOpen === 0) { + break; + } + + client.postMessage(JSON.stringify({ + command: 'open_event_stream', + clientBlacklist: [] + })); + --clientsToOpen; + } + } + }); + } else { + // notifications got disabled + // closing existing streams + self.clients + .matchAll({type: 'window', includeUncontrolled: false}) + .then((clients) => { + for (const client of clients) { + if (client.id in SERVER_CONNECTIONS) { + client.postMessage(JSON.stringify({ + command: 'close_event_stream' + })); + } + } + }); + } + + break; + case 'reject_open_event_stream': + // adds the client to the blacklist, as it rejected our request + data.clientBlacklist.push(event.source.id); + self.console.log(PREFIX + ` rejected the request to open an event stream`); + + selfSW.clients + .matchAll({type: 'window', includeUncontrolled: false}) + .then((clients) => { + for (const client of clients) { + if (! data.clientBlacklist.includes(client.id) && ! (client.id in SERVER_CONNECTIONS)) { + client.postMessage(JSON.stringify({ + command: 'open_event_stream', + clientBlacklist: data.clientBlacklist + })); + + return; + } + } + }); + + break; + } +} + +async function injectMiddleware(request, clientId) { + // define reference holders + const controllers = { + writable: undefined, + readable: undefined, + signal: new AbortController() + }; + const streams = { + writable: undefined, + readable: undefined, + pipe: undefined + }; + + // fetch event-stream and inject middleware + let response = await fetch(request, { + keepalive: true, + signal: controllers.signal.signal + }); + + if (! response.ok || response.status === 204 || ! response.body instanceof ReadableStream) { + return response; + } + + self.console.log(PREFIX + `injecting into data stream of `); + streams.readable = new ReadableStream({ + start(controller) { + controllers.readable = controller; + + // stream opened up, adding it to the active connections + SERVER_CONNECTIONS[clientId] = clientId; + }, + cancel(reason) { + self.console.log(PREFIX + ` closed event-stream (client-side)`); + + // request another opened up tab to take over the connection (if there's any) + self.clients + .matchAll({type: 'window', includeUncontrolled: false}) + .then((clients) => { + for (const client of clients) { + if (! (client.id in SERVER_CONNECTIONS) && client.id !== clientId) { + client.postMessage(JSON.stringify({ + command: 'open_event_stream', + clientBlacklist: [] + })); + + break; + } + } + }); + + removeActiveClient(clientId); + + // tab crashed or closed down connection to event-stream, stopping pipe through stream by + // triggering the abort signal (and stopping the writing stream as well) + controllers.signal.abort(); + } + }, new CountQueuingStrategy({highWaterMark: 10})); + streams.writable = 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 + ` closed event-stream (server-side)`); + removeActiveClient(clientId); + + // closing the reader as well + controllers.readable.close(); + }, + abort(reason) { + // close was triggered by an abort signal (most likely by the reader / client-side) + self.console.log(PREFIX + ` closed event-stream (server-side)`); + removeActiveClient(clientId); + } + }, new CountQueuingStrategy({highWaterMark: 10})); + + // returning injected (piped) stream + streams.pipe = response.body.pipeThrough({ + writable: streams.writable, + readable: streams.readable + }, {signal: controllers.signal.signal} + ); + + return new Response( + streams.pipe, + { + headers: response.headers, + statusText: response.statusText, + status: response.status + } + ); +} + +function removeActiveClient(clientId) { + // remove from active connections if it exists + if (clientId in SERVER_CONNECTIONS) { + delete SERVER_CONNECTIONS[clientId]; + } +} diff --git a/public/js/notifications.js b/public/js/notifications.js new file mode 100644 index 00000000..a50d9a98 --- /dev/null +++ b/public/js/notifications.js @@ -0,0 +1,423 @@ +/* Icinga Notifications Web | (c) 2024 Icinga GmbH | GPLv2 */ + +const VERSION = 1; + +(function (Icinga) { + + 'use strict'; + + const LOG_PREFIX = '[Notification] - '; + + if (! 'ServiceWorker' in self) { + console.error(LOG_PREFIX + "this browser does not support the 'Service Worker API' in the current context"); + + return; + } + + if (! 'Navigator' in self) { + console.error(LOG_PREFIX + "this browser does not support the 'Navigator API' in the current context"); + + return; + } + + if (! 'Notification' in self) { + console.error(LOG_PREFIX + "this browser does not support the 'Notification API' in the current context"); + + return; + } + + Icinga.Behaviors = Icinga.Behaviors || {}; + + class Notification extends Icinga.EventListener { + eventSource = null; + toggleState = null; + initialized = false; + allowedToOperate = false; + + constructor(icinga) { + super(icinga); + + // only allow to be instantiated in a web context + if (! self instanceof Window) { + this.logger.error(LOG_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; + this.toggleState = new Icinga.Storage.StorageAwareMap + .withStorage(Icinga.Storage.BehaviorStorage('notification'), 'toggle') + + // listen for events + this.on('icinga-init', null, this.onInit, this); + this.on('rendered', '#main > .container', this.renderHandler, this); + + this.logger.debug(LOG_PREFIX + "spawned"); + } + + onInit(event) { + event.data.self.load(); + } + + load() { + this.logger.debug(LOG_PREFIX + "loading"); + + // listen to controller (service worker) changes + navigator.serviceWorker.addEventListener('controllerchange', (event) => { + this.logger.debug(LOG_PREFIX + "new controller attached ", event.target.controller); + if (event.target.controller !== null) { + // reset eventsource and handshake flag + this.allowedToOperate = false; + this.closeEventStream(); + + this.logger.debug(LOG_PREFIX + "send handshake to controller"); + event.target.controller.postMessage( + JSON.stringify({ + command: 'handshake', + version: VERSION + }) + ); + } + }); + + // listen to messages from the controller (service worker) + self.navigator.serviceWorker.addEventListener('message', (event) => { + if (! event.data) { + return; + } + + let data = JSON.parse(event.data); + switch (data.command) { + case 'handshake': + if (data.status === 'outdated') { + this.logger.debug( + LOG_PREFIX + + "handshake got rejected as we're running an outdated script version" + ); + + // the controller declared us as an outdated script version + this.icinga.loader.createNotice( + 'warning', + 'This tab is running an outdated script version. Please reload the page!', + true + ); + + this.allowedToOperate = false; + } else { + this.logger.debug( + LOG_PREFIX + + "handshake got accepted by the controller" + ); + + this.allowedToOperate = true; + if ( + this.initialized + && this.hasNotificationPermission() + && this.hasNotificationsEnabled() + ) { + setTimeout(() => { + this.openEventStream(); + }, 2000); + } + } + + break; + case 'open_event_stream': + // service worker requested us to open up an event-stream + if (! this.allowedToOperate) { + // we are not allowed to open up connections, rejecting the request + this.logger.debug( + LOG_PREFIX + + "rejecting the request to open up an event-stream as this tab is not allowed" + + " to (failed the handshake with the controller)" + ); + event.source.postMessage( + JSON.stringify({ + command: 'reject_open_event_stream', + clientBlacklist: data.clientBlacklist + }) + ); + } else { + this.openEventStream(); + } + + break; + case 'close_event_stream': + // service worker requested us to stop our event-stream + this.closeEventStream(); + + break; + } + }); + + // register service worker if it is not already + this.getServiceWorker() + .then((serviceWorker) => { + if (! serviceWorker) { + // 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 callback = (event) => { + if (event.target.state === 'activated') { + registration.removeEventListener('statechange', callback); + + registration.active.postMessage( + JSON.stringify({ + command: 'handshake', + version: VERSION + }) + ); + } + }; + registration.addEventListener('statechange', callback); + }); + } else { + // service worker is already running, announcing ourselves + serviceWorker.postMessage( + JSON.stringify({ + command: 'handshake', + version: VERSION + }) + ) + } + }) + .finally(() => { + this.logger.debug(LOG_PREFIX + "loaded"); + }) + } + + unload() { + this.logger.debug(LOG_PREFIX + "unloading"); + + // disconnect EventSource if there's an active connection + this.closeEventStream(); + this.eventSource = null; + this.initialized = false; + + this.logger.debug(LOG_PREFIX + "unloaded"); + } + + reload() { + this.unload(); + this.load(); + } + + openEventStream() { + if (! this.hasNotificationPermission() || ! this.hasNotificationsEnabled()) { + this.logger.warn(LOG_PREFIX + "denied opening event-stream as the notification permissions" + + " are missing or the notifications themselves disabled"); + + return; + } + + // close existing event source object if there's one + this.closeEventStream(); + + try { + this.logger.debug(LOG_PREFIX + "opening event source"); + this.eventSource = new EventSource( + icinga.config.baseUrl + '/notifications/v1/subscribe', + {withCredentials: true} + ); + this.eventSource.addEventListener('icinga2.notification', (event) => { + if (! this.hasNotificationPermission() || ! this.hasNotificationsEnabled()) { + return; + } + + // send to service_worker if the permissions are given and the notifications enabled + this.getServiceWorker() + .then((serviceWorker) => { + if (serviceWorker) { + serviceWorker.postMessage( + JSON.stringify({ + command: 'notification', + notification: JSON.parse(event.data), + baseUrl: icinga.config.baseUrl + }) + ); + } + }); + }); + } catch (error) { + this.logger.error(LOG_PREFIX + `got an error while trying to open up an event-stream:`, error); + } + } + + closeEventStream() { + if (this.eventSource !== null && this.eventSource.readyState !== EventSource.CLOSED) { + this.eventSource.close(); + } + } + + renderHandler(event) { + const _this = event.data.self; + let url = new URL(event.delegateTarget.URL); + + /** + * TODO(nc): We abuse the fact that the renderHandler method only triggers when the container + * in col1 (#main > #col1.container) gets rendered. This can only happen on the main interface for + * now (might break things if columns are introduced elsewhere in the future). + * This in turn requires a user to be logged in and their session validated. + * In the future, we should introduce a proper login event and tie the initial event-stream connection + * to this specific event (SSO should ALSO trigger the login event as the user lands in the + * interface with an authenticated session). + */ + if (_this.initialized === false) { + _this.initialized = true; + if (_this.allowedToOperate && _this.hasNotificationPermission() && _this.hasNotificationsEnabled()) { + _this.openEventStream(); + } + } + + if (url.pathname !== _this.icinga.config.baseUrl + '/account') { + return; + } + + // check permissions and storage flag + const state = _this.hasNotificationPermission() && _this.hasNotificationsEnabled(); + + // account page got rendered, injecting notification toggle + const container = event.target; + const form = container.querySelector('.content > form[name=form_config_preferences]'); + const submitButtons = form.querySelector('div > input[type=submit]').parentNode; + + // build toggle + const toggle = document.createElement('div'); + toggle.classList.add('control-group'); + + // div .control-label-group + const toggleLabelGroup = document.createElement('div'); + toggleLabelGroup.classList.add('control-label-group'); + toggle.appendChild(toggleLabelGroup); + const toggleLabelSpan = document.createElement('span'); + toggleLabelSpan.setAttribute('id', 'form_config_preferences_enable_notifications-label'); + toggleLabelSpan.textContent = 'Enable notifications'; + toggleLabelGroup.appendChild(toggleLabelSpan); + const toggleLabel = document.createElement('label'); + toggleLabel.classList.add('control-label'); + toggleLabel.classList.add('optional'); + toggleLabel.setAttribute('for', 'form_config_preferences_enable_notifications'); + toggleLabelSpan.appendChild(toggleLabel); + + // input .sr-only + const toggleInput = document.createElement('input'); + toggleInput.setAttribute('id', 'form_config_preferences_enable_notifications'); + toggleInput.classList.add('sr-only'); + toggleInput.setAttribute('type', 'checkbox'); + toggleInput.setAttribute('name', 'show_notifications'); + toggleInput.setAttribute('value', state ? '1' : '0'); + if (state) { + toggleInput.setAttribute('checked', 'checked'); + } + toggle.appendChild(toggleInput); + // listen to toggle changes + toggleInput.addEventListener('change', () => { + if (toggleInput.checked) { + toggleInput.setAttribute('value', '1'); + toggleInput.setAttribute('checked', 'checked'); + + if (_this.hasNotificationPermission() === false) { + // ask for notification permission + window.Notification.requestPermission() + .then((permission) => { + if (permission !== 'granted') { + // reset toggle back to unchecked as the permission got denied + toggleInput.checked = false; + } + }) + .catch((_) => { + // permission is not allowed in this context, resetting toggle + toggleInput.checked = false; + }); + } + } else { + toggleInput.setAttribute('value', '0'); + toggleInput.removeAttribute('checked'); + } + }); + + // label .toggle-switch + const toggleSwitch = document.createElement('label'); + toggleSwitch.classList.add('toggle-switch'); + toggleSwitch.setAttribute('for', 'form_config_preferences_enable_notifications'); + toggleSwitch.setAttribute('aria-hidden', 'true'); + toggle.appendChild(toggleSwitch); + const toggleSwitchSlider = document.createElement('span'); + toggleSwitchSlider.classList.add('toggle-slider'); + toggleSwitch.appendChild(toggleSwitchSlider); + + form.insertBefore(toggle, submitButtons); + + // listen to submit event to update storage flag if needed + form.addEventListener('submit', () => { + let hasChanged = false; + if (toggleInput.checked) { + // notifications are enabled + if (_this.hasNotificationPermission()) { + if (_this.toggleState.has('enabled') === false || (_this.toggleState.get('enabled') !== true)) { + _this.toggleState.set('enabled', true); + hasChanged = true; + } + } + } else { + // notifications are disabled + if (_this.toggleState.has('enabled')) { + _this.toggleState.delete('enabled'); + + hasChanged = true; + } + } + + if (hasChanged) { + // inform service worker about the toggle change + _this.getServiceWorker() + .then((serviceWorker) => { + if (serviceWorker) { + serviceWorker.postMessage( + JSON.stringify({ + command: 'storage_toggle_update', + state: toggleInput.checked + }) + ); + } + }); + } + }); + } + + hasNotificationsEnabled() { + return ( + (this.toggleState !== null) && + (this.toggleState.has('enabled')) && + (this.toggleState.get('enabled') === true) + ); + } + + hasNotificationPermission() { + return ('Notification' in window) && (window.Notification.permission === 'granted'); + } + + async getServiceWorker() { + let serviceWorker = await self.navigator.serviceWorker + .getRegistration(icinga.config.baseUrl + '/'); + + if (serviceWorker) { + switch (true) { + case serviceWorker.installing !== null: + return serviceWorker.installing; + case serviceWorker.waiting !== null: + return serviceWorker.waiting; + case serviceWorker.active !== null: + return serviceWorker.active; + } + } + + return null; + } + } + + Icinga.Behaviors.Notification = Notification; +})(Icinga); diff --git a/run.php b/run.php index eac12b7c..9e94cef8 100644 --- a/run.php +++ b/run.php @@ -2,6 +2,22 @@ /* Icinga Notifications Web | (c) 2024 Icinga GmbH | GPLv2 */ -/** @var $this \Icinga\Application\Modules\Module */ +/** @var \Icinga\Application\Modules\Module $this */ $this->provideHook('Notifications/ObjectsRenderer'); +$this->provideHook('authentication', 'SessionStorage', true); +$this->addRoute( + 'static-file', + new Zend_Controller_Router_Route_Regex( + 'notifications-(.[^.]*)(\..*)', + [ + 'controller' => 'daemon', + 'action' => 'script', + 'module' => 'notifications' + ], + [ + 1 => 'file', + 2 => 'extension' + ] + ) +);