From 5d03301a5938f860d5bdfa9267dc2e6ebdd8f2d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?No=C3=A9=20Costa?= Date: Mon, 27 Nov 2023 11:03:58 +0100 Subject: [PATCH] feat(desktop-notifications): first iteration for desktop notifications This feature allows the notification module to send desktop notifications to a connected browser. It uses the Service Worker API which is used as a middleware between the client and the server. This API intercepts SSE requests and pools them to a single connection by browser. This functionality uses server-sent events (SSE) to push notifications to a connected browser. --- application/clicommands/DaemonCommand.php | 12 + application/controllers/DaemonController.php | 52 +++ configuration.php | 2 + library/Notifications/Daemon/Daemon.php | 264 +++++++++++++++ library/Notifications/Daemon/Server.php | 317 ++++++++++++++++++ .../Notifications/Model/Daemon/Connection.php | 127 +++++++ library/Notifications/Model/Daemon/Event.php | 91 +++++ .../Model/Daemon/EventIdentifier.php | 68 ++++ .../Notifications/Model/Daemon/Session.php | 55 +++ library/Notifications/Model/Daemon/User.php | 36 ++ .../ProvidedHook/SessionStorage.php | 128 +++++++ public/js/icinga-notifications-worker.js | 196 +++++++++++ public/js/notification.js | 109 ++++++ run.php | 15 + 14 files changed, 1472 insertions(+) create mode 100644 application/clicommands/DaemonCommand.php create mode 100644 application/controllers/DaemonController.php create mode 100644 library/Notifications/Daemon/Daemon.php create mode 100644 library/Notifications/Daemon/Server.php create mode 100644 library/Notifications/Model/Daemon/Connection.php create mode 100644 library/Notifications/Model/Daemon/Event.php create mode 100644 library/Notifications/Model/Daemon/EventIdentifier.php create mode 100644 library/Notifications/Model/Daemon/Session.php create mode 100644 library/Notifications/Model/Daemon/User.php create mode 100644 library/Notifications/ProvidedHook/SessionStorage.php create mode 100644 public/js/icinga-notifications-worker.js create mode 100644 public/js/notification.js create mode 100644 run.php diff --git a/application/clicommands/DaemonCommand.php b/application/clicommands/DaemonCommand.php new file mode 100644 index 000000000..0d596fc69 --- /dev/null +++ b/application/clicommands/DaemonCommand.php @@ -0,0 +1,12 @@ +_helper->viewRenderer->setNoRender(true); + $this->_helper->layout()->disableLayout(); + } + + public function scriptAction(): void { + $root = Icinga::app() + ->getModuleManager() + ->getModule('notifications') + ->getBaseDir() . '/public/js'; + + $filePath = realpath($root . DIRECTORY_SEPARATOR . 'icinga-notifications-worker.js'); + if($filePath === false) { + $this->httpNotFound("'icinga-notifications-worker.js' does not exist"); + } + + $fileStat = stat($filePath); + $eTag = sprintf( + '%x-%x-%x', + $fileStat['ino'], + $fileStat['size'], + (float)str_pad($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', 'text/javascript', true) + ->setHeader('Last-Modified', gmdate('D, d M Y H:i:s', $fileStat['mtime']) . ' GMT') + ->setBody(file_get_contents($filePath)); + } + } +} \ No newline at end of file diff --git a/configuration.php b/configuration.php index efb1f86ad..d916da64d 100644 --- a/configuration.php +++ b/configuration.php @@ -76,3 +76,5 @@ 'url' => 'channels' ] ); + +$this->provideJsFile('notification.js'); diff --git a/library/Notifications/Daemon/Daemon.php b/library/Notifications/Daemon/Daemon.php new file mode 100644 index 000000000..fe6b4a27b --- /dev/null +++ b/library/Notifications/Daemon/Daemon.php @@ -0,0 +1,264 @@ +load(); + } + + public static function get(): Daemon { + if(isset(self::$instance) === false) { + self::$instance = new Daemon(); + } + + return self::$instance; + } + + private function load(): void { + self::$logger::debug(self::PREFIX . "loading"); + + $this->loop = Loop::get(); + $this->signalHandling($this->loop); + $this->server = Server::get($this->loop); + $this->database = Database::get(); + $this->database->connect(); + $this->cancellationToken = false; + $this->initializedAt = time(); + $this->run(); + + self::$logger::debug(self::PREFIX . "loaded"); + } + + public function unload(): void { + self::$logger::debug(self::PREFIX . "unloading"); + + $this->cancellationToken = true; + $this->database->disconnect(); + $this->server->unload(); + $this->loop->stop(); + + unset($this->initializedAt); + unset($this->database); + unset($this->server); + unset($this->loop); + + self::$logger::debug(self::PREFIX . "unloaded"); + } + + public function reload(): void { + self::$logger::debug(self::PREFIX . "reloading"); + + $this->unload(); + $this->load(); + + self::$logger::debug(self::PREFIX . "reloaded"); + } + + private function shutdown(bool $isManualShutdown = false): void { + 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); + } + + private 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); + } + + private function housekeeping(): void { + self::$logger::debug(self::PREFIX . "running housekeeping job"); + $staleSessions = Session::on(Database::get()) + ->filter(Filter::lessThan('authenticated_at', time() - 86400)); + $deletions = 0; + + /** @var Session $session */ + foreach($staleSessions as $session) { + $this->database->delete( + 'session', + [ + 'id = ?' => $session->id + ] + ); + ++$deletions; + } + + if($deletions > 0) { + self::$logger::info(self::PREFIX . "housekeeping cleaned " . $deletions . " stale sessions"); + } + self::$logger::debug(self::PREFIX . "finished housekeeping job"); + } + + private function processNotifications(): void { + $numOfNotifications = 0; + + if(isset($this->lastIncidentId) === false) { + // get the newest incident identifier + $latestIncidentNotification = IncidentHistory::on(Database::get()) + ->filter(Filter::equal('type', 'notified')) + ->orderBy('id', 'DESC') + ->first(); + if($latestIncidentNotification) { + $this->lastIncidentId = intval($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()) + ->filter(Filter::greaterThan('id', $this->lastIncidentId)) + ->filter(Filter::equal('type', 'notified')) + ->orderBy('id', 'ASC'); + /** @var array<\Icinga\Module\Notifications\Model\Daemon\Connection> $connections */ + $connections = $this->server->getMatchedConnections(); + + /** @var IncidentHistory $notification */ + foreach($notifications as $notification) { + if(isset($connections[$notification->contact_id])) { + /** @var Incident $incident */ + $incident = IncidentHistory::on(Database::get()) + ->filter(Filter::equal('id', $notification->caused_by_incident_history_id)) + ->with([ + 'incident' + ]) + ->first(); + if($incident !== null) { + // reformat notification time + /** @var DateTime $time */ + $time = $incident->time; + $time->setTimezone(new DateTimeZone('UTC')); + $time = $time->format(DateTimeInterface::RFC3339_EXTENDED); + + $connections[$notification->contact_id]->sendEvent(new Event( + EventIdentifier::ICINGA2_NOTIFICATION, + (object)[ + 'incident_id' => $incident->incident_id, + 'event_id' => $incident->event_id, + '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) + )); + ++$numOfNotifications; + } + } + + $this->lastIncidentId = $notification->id; + } + + if($numOfNotifications > 0) { + self::$logger::debug(self::PREFIX . "sent " . $numOfNotifications . " notifications"); + } + } + + private 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(); + }); + } +} \ No newline at end of file diff --git a/library/Notifications/Daemon/Server.php b/library/Notifications/Daemon/Server.php new file mode 100644 index 000000000..402c88bdd --- /dev/null +++ b/library/Notifications/Daemon/Server.php @@ -0,0 +1,317 @@ + + */ + private $connections; + + /** + * @var \ipl\Sql\Connection + */ + private $dbLink; + + private function __construct(LoopInterface $mainLoop) { + self::$logger = Logger::getInstance(); + self::$logger::debug(self::PREFIX . "spawned"); + + $this->mainLoop = $mainLoop; + $this->dbLink = Database::get(); + + $this->load(); + } + + public static function get(LoopInterface $mainLoop): Server { + if(isset(self::$instance) === false) { + self::$instance = new Server($mainLoop); + } elseif(isset(self::$instance->mainLoop) && (self::$instance->mainLoop !== $mainLoop)) { + // main loop changed, reloading daemon server + self::$instance->mainLoop = $mainLoop; + self::$instance->reload(); + } + return self::$instance; + } + + private function load(): void { + self::$logger::debug(self::PREFIX . "loading"); + + $this->connections = []; + $this->socket = new SocketServer('[::]:9001', [], $this->mainLoop); + $this->http = new HttpServer(function(ServerRequestInterface $request) { + return $this->handleRequest($request); + }); + // subscribe to socket events + $this->socket->on('connection', function(ConnectionInterface $connection) { + $this->onSocketConnection($connection); + }); + $this->socket->on('error', function($error) { + $this->onSocketError($error); + }); + // attach http server to socket + $this->http->listen($this->socket); + + self::$logger::debug(self::PREFIX . "loaded"); + } + + 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"); + } + + public function reload(): void { + self::$logger::debug(self::PREFIX . "reloading"); + + $this->unload(); + $this->load(); + + self::$logger::debug(self::PREFIX . "reloaded"); + } + + private function mapRequestToConnection(ServerRequestInterface $request): ?Connection { + $params = $request->getServerParams(); + if(isset($params['REMOTE_ADDR']) && isset($params['REMOTE_PORT'])) { + $address = Connection::parseHostAndPort($params['REMOTE_ADDR'] . ':' . $params['REMOTE_PORT']); + foreach($this->connections as $connection) { + if($connection->getAddress() === $address->addr) { + return $connection; + } + } + } + return null; + } + + private function onSocketConnection(ConnectionInterface $connection): void { + $address = Connection::parseHostAndPort($connection->getRemoteAddress()); + + // subscribe to events on this connection + $connection->on('data', function($data) use ($connection) { + $this->onConnectionData($connection, $data); + }); + $connection->on('end', function() use ($connection) { + $this->onConnectionEnd($connection); + }); + $connection->on('error', function($error) use ($connection) { + $this->onConnectionError($connection, $error); + }); + $connection->on('close', function() use ($connection) { + $this->onConnectionClose($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); + } + + private function onSocketError($error): void { + // TODO: ADD error handling + } + + private function onConnectionData(ConnectionInterface $connection, string $data): void {} + + private function onConnectionEnd(ConnectionInterface $connection): void {} + + private function onConnectionError(ConnectionInterface $connection, Exception $error): void {} + + private function onConnectionClose(ConnectionInterface $connection): void { + // delete the reference to this connection if we have been actively tracking it + $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]); + } + } + + private 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" + ], + '' + ); + } + + // 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: 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: 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"); + return new Response( + StatusCodeInterface::STATUS_OK, + [ + "Content-Type" => "text/event-stream; charset=utf-8", + "Cache-Control" => "no-cache", + "X-Accel-Buffering" => "no" + ], + $connection->getStream() + ); + } + + private function authenticate(Connection $connection, array $cookies, array $headers): stdClass { + $data = new stdClass(); + + if(array_key_exists('Icingaweb2', $cookies)) { + // session id is supplied, check for the existence of a user-agent header as it's needed to calculate the device id + if(array_key_exists('User-Agent', $headers) && sizeof($headers['User-Agent']) === 1) { + // grab session + $session = Session::on($this->dbLink) + ->filter(Filter::equal('id', htmlspecialchars(trim($cookies['Icingaweb2'])))) + ->first(); + + // calculate device id + $deviceId = Connection::calculateDeviceId($headers['User-Agent'][0], $session->username) ?: 'default'; + + // check if device id of connection corresponds to device id of authenticated session + if($deviceId === $session->device_id) { + // making sure that it's the latest session + $latestSession = Session::on($this->dbLink) + ->filter(Filter::equal('username', $session->username)) + ->filter(Filter::equal('device_id', $session->device_id)) + ->orderBy('authenticated_at', 'DESC') + ->first(); + if(isset($latestSession) && ($latestSession->id === $session->id)) { + // current session is the latest session for this user and device => this is a valid request + $data->session_id = $session->id; + $data->user = $session->username; + $data->device_id = $session->device_id; + $connection->setSession($data->session_id); + $connection->getUser()->setUsername($data->user); + $connection->setDeviceId($data->device_id); + $data->isValid = true; + return $data; + } + } + } + } + + // the request is invalid, return this result + $data->isValid = false; + return $data; + } + + private function matchContact(string $username): ?int { + /** + * TODO: the matching needs to be properly rewritten once we decide about how we want to handle the contacts in the notifications module + */ + $contact = Contact::on(Database::get()) + ->filter(Filter::equal('username', $username)) + ->first(); + if($contact !== null) { + return intval($contact->id); + } + return null; + } + + public function getMatchedConnections(): array { + $connections = []; + foreach($this->connections as $connection) { + $contactId = $connection->getUser()->getContactId(); + if(isset($contactId)) { + $connections[$contactId] = $connection; + } + } + + return $connections; + } +} diff --git a/library/Notifications/Model/Daemon/Connection.php b/library/Notifications/Model/Daemon/Connection.php new file mode 100644 index 000000000..df5bb4d25 --- /dev/null +++ b/library/Notifications/Model/Daemon/Connection.php @@ -0,0 +1,127 @@ +connection = $connection; + + $address = $this->parseHostAndPort($connection->getRemoteAddress()); + $this->host = $address->host; + $this->port = (int)$address->port; + + $this->stream = new ThroughStream(); + $this->session = null; + $this->user = new User(); + $this->deviceId = null; + } + + public static function parseHostAndPort(string $address): stdClass { + $raw = $address; + $combined = new stdClass(); + $combined->host = substr($raw, strpos($raw, '[') + 1, strpos($raw, ']') - (strpos($raw, '[') + 1)); + if(strpos($combined->host, '.')) { + // it's an IPv4, stripping empty IPv6 tags + $combined->host = substr($combined->host, strrpos($combined->host, ':') + 1); + } + $combined->port = substr($raw, strpos($raw, ']') + 2); + $combined->addr = $combined->host . ':' . $combined->port; + + return $combined; + } + + public static function calculateDeviceId(string $userAgent, string $user): ?string { + if(in_array('joaat', hash_algos())) { + if(trim(strlen($userAgent)) > 0 && trim(strlen($user)) > 0) { + return strtoupper(hash('joaat', $user . trim($userAgent))); + } + } + + return null; + } + + 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 setSession(string $session): void { + $this->session = $session; + } + + public function getStream(): ThroughStream { + return $this->stream; + } + + public function getConnection(): ConnectionInterface { + return $this->connection; + } + + public function getUser(): User { + return $this->user; + } + + public function getDeviceId(): ?string { + return $this->deviceId; + } + + public function setDeviceId(string $deviceId): void { + $this->deviceId = $deviceId; + } + + public function sendEvent(Event $event): void { + $this->stream->write( + $event + ); + } +} \ No newline at end of file diff --git a/library/Notifications/Model/Daemon/Event.php b/library/Notifications/Model/Daemon/Event.php new file mode 100644 index 000000000..c71936e54 --- /dev/null +++ b/library/Notifications/Model/Daemon/Event.php @@ -0,0 +1,91 @@ +identifier = $identifier; + $this->data = $data; + $this->reconnectInterval = 3000; + $this->lastEventId = $lastEventId; + + // TODO: Replace with hrtime(true) once the lowest supported PHP version raises to 7.3 + $this->createdAt = new DateTime(); + } + + final public function getIdentifier(): string { + return $this->identifier; + } + + final public function getData(): stdClass { + return $this->data; + } + + final public function getCreatedAt(): string { + return $this->createdAt->format(DateTimeInterface::RFC3339_EXTENDED); + } + + final public function getReconnectInterval(): int { + return $this->reconnectInterval; + } + + final public function setReconnectInterval(int $reconnectInterval): void { + $this->reconnectInterval = $reconnectInterval; + } + + final public function getLastEventId(): int { + return $this->lastEventId; + } + + private 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; + } + + final 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 000000000..0587a2196 --- /dev/null +++ b/library/Notifications/Model/Daemon/EventIdentifier.php @@ -0,0 +1,68 @@ + t('Session Identifier'), + 'username' => t('Username'), + 'device_id' => t('Device Identifier'), + 'authenticated_at' => t('Authenticated At') + ]; + } + + public function getSearchColumns(): array { + return [ + 'id', + 'username', + 'device_id' + ]; + } + + public function createBehaviors(Behaviors $behaviors) { + $behaviors->add(new MillisecondTimestamp([ + 'authenticated_at' + ])); + } + + +} diff --git a/library/Notifications/Model/Daemon/User.php b/library/Notifications/Model/Daemon/User.php new file mode 100644 index 000000000..f2a7ebf9b --- /dev/null +++ b/library/Notifications/Model/Daemon/User.php @@ -0,0 +1,36 @@ +username = null; + $this->contactId = null; + } + + public function getUsername(): ?string { + return $this->username; + } + + public function setUsername(string $username): void { + $this->username = $username; + } + + public function getContactId(): ?int { + return $this->contactId; + } + + public function setContactId(int $contactId): void { + $this->contactId = $contactId; + } +} diff --git a/library/Notifications/ProvidedHook/SessionStorage.php b/library/Notifications/ProvidedHook/SessionStorage.php new file mode 100644 index 000000000..3980f06ae --- /dev/null +++ b/library/Notifications/ProvidedHook/SessionStorage.php @@ -0,0 +1,128 @@ +session = \Icinga\Web\Session::getSession(); + $this->database = Database::get(); + } + + public function onLogin(User $user) { + Logger::info('running onLogin hook'); + + if($this->session->exists()) { + // user successfully authenticated + // calculate device identifier + $userAgent = $_SERVER['HTTP_USER_AGENT'] ?: null; + $deviceId = Connection::calculateDeviceId($userAgent, $user->getUsername()) ?: 'default'; + + // check if session with this identifier already exists (zombie session) + $zombieSession = Session::on(Database::get()) + ->filter(Filter::equal('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( + 'session', + [ + 'id = ?' => $this->session->getId() + ] + ); + $this->database->commitTransaction(); + } catch(PDOException $e) { + Logger::error("Failed deleting session from table 'session': \n\t" . $e->getMessage()); + $this->database->rollBackTransaction(); + } + } + + // cleanup existing sessions from this user (only for the current device) + $userSessions = Session::on(Database::get()) + ->filter(Filter::equal('username', $user->getUsername())) + ->filter(Filter::equal('device_id', $deviceId)) + ->execute(); + foreach($userSessions as $session) { + $this->database->delete( + 'session', + [ + 'id = ?' => $session->id, + 'username = ?' => trim($user->getUsername()), + 'device_id = ?' => $deviceId + ] + ); + } + + // add current session to the db + $this->database->beginTransaction(); + try { + $this->database->insert( + 'session', + [ + 'id' => $this->session->getId(), + 'username' => trim($user->getUsername()), + 'device_id' => $deviceId + ] + ); + $this->database->commitTransaction(); + } catch(PDOException $e) { + Logger::error("Failed adding session to table 'session': \n\t" . $e->getMessage()); + $this->database->rollBackTransaction(); + } + Logger::debug("onLogin triggered for user " . $user->getUsername() . " and session " . $this->session->getId()); + } + } + + public function onLogout(User $user) { + if($this->session->exists()) { + // user disconnected, removing the session from the database (invalidating it) + if($this->database->ping() === false) { + $this->database->connect(); + } + + // calculate device identifier + $userAgent = $_SERVER['HTTP_USER_AGENT'] ?: null; + $deviceId = Connection::calculateDeviceId($userAgent, $user->getUsername()) ?: 'default'; + + $this->database->beginTransaction(); + try { + $this->database->delete( + 'session', + [ + 'id = ?' => $this->session->getId(), + 'username = ?' => trim($user->getUsername()), + 'device_id = ?' => $deviceId + ] + ); + $this->database->commitTransaction(); + } catch(PDOException $e) { + Logger::error("Failed deleting session from table 'session': \n\t" . $e->getMessage()); + $this->database->rollBackTransaction(); + } + Logger::debug("onLogout triggered for user " . $user->getUsername() . " and session " . $this->session->getId()); + } + } + +} \ No newline at end of file diff --git a/public/js/icinga-notifications-worker.js b/public/js/icinga-notifications-worker.js new file mode 100644 index 000000000..043cfaa2f --- /dev/null +++ b/public/js/icinga-notifications-worker.js @@ -0,0 +1,196 @@ +/** + * version: 0.0.1 (rev: 1) + */ +'use strict'; + +class Logger { + constructor(caller) { + this.console = caller.console; + this._PREFIX = 'icinga-notifications-worker'; + this._SEVERITY_DEBUG = 'DEBUG'; + this._SEVERITY_INFO = 'INFO'; + this._SEVERITY_WARN = 'WARN'; + this._SEVERITY_ERROR = 'ERROR'; + this._SEVERITY_FATAL = 'FATAL'; + } + + _log(severity, message) { + this.console.log(this._PREFIX.toString() + ' - [' + severity + '] :: ' + message); + } + + _error(severity, message) { + this.console.error(this._PREFIX + ' - [' + severity + '] :: ' + message); + } + + debug(message) { + this._log(this._SEVERITY_DEBUG, message); + } + + info(message) { + this._log(this._SEVERITY_INFO, message); + } + + warn(message) { + this._log(this._SEVERITY_WARN, message); + } + + error(message) { + this._error(this._SEVERITY_ERROR, message); + } + + fatal(message) { + this._error(this._SEVERITY_FATAL, message); + throw new Error(message); + } +} + +class Worker { + constructor(caller) { + this.caller = caller; + this.logger = new Logger(this.caller); + this._connections = []; + this._proxyStreams = []; + this._init(); + } + + _init() { + this.logger.debug('Initialized.'); + } + + _load() { + this.logger.debug('Loaded.'); + } + + _unload() { + this.logger.debug('Unloaded.'); + } + + /** + * @param eventSource EventSource + * @private + */ + _listenToEventSource(eventSource) { + if (eventSource instanceof EventSource) { + eventSource.addEventListener('message', (/** ExtendableMessageEvent event */ event) => { + this.logger.debug(`${eventSource.url} [${event.type}] => Unhandled event occurred: ${event.data}.`); + }); + eventSource.addEventListener('error', () => { + if (eventSource.readyState === EventSource.CONNECTING) { + this.logger.debug(`${eventSource.url} [error] => Stream failed. Trying to reconnect...`); + } else if (eventSource.readyState === EventSource.CLOSED) { + this.logger.debug(`${eventSource.url} [error] => Stream failed and won't reconnect anymore.`); + // removing the entry from the event sources + delete this._connections[eventSource.url]; + } + }); + eventSource.addEventListener('open', (event) => { + this.logger.debug(`${eventSource.url} [open] => Stream opened up.`); + }); + eventSource.addEventListener('icinga2.notification', (event) => { + let data = JSON.parse(event.data); + // FIXME: Add proper notification display once we're sure what and how we want to display + self.registration.showNotification( + '#' + event.lastEventId + ' - Incident is ' + data.payload.severity, + { + icon: 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNTYiIGhlaWdodD0iMjU2Ij48cGF0aCBzdHlsZT0ic3Ryb2tlOm5vbmU7ZmlsbC1ydWxlOm5vbnplcm87ZmlsbDojZmZmO2ZpbGwtb3BhY2l0eToxIiBkPSJNMzIuNjg4IDI1My4yMzhjLTkuNjI2LTEuMDM5LTE4LjM1Mi03LjAyNy0yMi43MjctMTUuNTc0LTIuMDM1LTMuOTg4LTMuMDM1LTcuOTAyLTMuMTcyLTEyLjQyNi0uMTUyLTQuOTM3Ljc2Mi05LjI1IDIuODg3LTEzLjU3NGEyOS4zMzcgMjkuMzM3IDAgMCAxIDEwLjg4Ni0xMi4wMzljMy40NS0yLjE0OCA4LjA2My0zLjcgMTIuMjktNC4xMzcgMi4wNDYtLjIxNSA1LjgxMi0uMDkgNy43NzMuMjYyIDMuOTYuNyA3LjgxMyAyLjIzOCAxMS4xMzcgNC40NSAxLjA2Mi43MTQgMS4yNzcuODEyIDEuNDE0LjY2My4yMjItLjIzOCAyNS41ODYtMzYuNjM2IDI1LjY2LTM2LjgxMi4wMjctLjA5LS41ODYtLjU5LTEuMzYtMS4xMTNBNDIuODIgNDIuODIgMCAwIDEgNTkuNyAxMzZjLS42MjUtMy4xNDgtLjc1LTQuNTYzLS43NS04LjM3NSAwLTMuOC4xMjUtNS4yMjcuNzUtOC4zNzUgMS4yMjctNi4xODggMy44NzUtMTIuMTc2IDcuNjE0LTE3LjIuNzUtMS4wMSAxLjA2Mi0xLjUyNy45ODgtMS42MjQtLjA2My0uMDktNS4wNjMtNC4yMTUtMTEuMTAyLTkuMTUzbC0xMS05LS44NzUuNzc4Yy0yLjQ4OCAyLjE2LTUuMjM4IDMuMTk5LTguNTExIDMuMTk5LTMuNjE0IDAtNi41NjMtMS4yMjctOS4xMzctMy44LTEuOTI2LTEuOTM4LTMuMDQtMy45NzMtMy41NzQtNi41NjMtLjMyOS0xLjU4Ni0uMjY2LTQuMzEzLjE0OC01LjgyNS41NC0yIDEuNzEtNC4wODUgMy4xNDgtNS42MjQgMy40MjYtMy42NzYgOC45NTQtNS4wNCAxMy42MjUtMy4zNjQgNy4wOSAyLjU0IDEwLjU0IDEwLjEyNSA3Ljc1IDE3LjA5LS40MjEgMS4wODYtLjQyMSAxLjExLS4xOTkgMS4zMzYuMTM3LjExMyA1LjIgNC4yNzMgMTEuMjc4IDkuMjM4bDExLjAyMyA5LjAyNCAxLjQxNC0xLjMxM2MxLjczNC0xLjYyNSAzLjA2My0yLjcyMiA0LjQ4NC0zLjczOCA2LjE1My00LjM0OCAxMi42MTQtNi44MzYgMjAuMTY1LTcuNzg1IDIuMTI0LS4yNjIgNy4wNzQtLjMxMyA5LjEyNC0uMDc0IDQuMTAyLjQ3MiA4LjM3NiAxLjUxMSAxMS40NSAyLjc4NS41LjE5OS45NDkuMzI0IDEgLjI3Ny4xMjUtLjE1MiAyMC40ODgtNDIuNTI3IDIwLjQ4OC00Mi42NTIgMC0uMDYzLS40MTQtLjM0OC0uOTI2LS42MjUtMi4yNjItMS4yNS01LTMuNjE0LTYuNjQ4LTUuNy00LjM1Mi01LjU3NC01LjgyNC0xMi42OTktNC4wNTEtMTkuNTM5IDIuMjEtOC40NzIgOS4yMzgtMTQuOTM3IDE3LjkzOC0xNi40NzIgMS41ODUtLjI3OCA0LjQ3Ni0uMzY0IDYuMDYyLS4xODggNi44Ljc4NSAxMi42NjQgNC4zMTMgMTYuMzg3IDkuODc1IDMuODQgNS43NSA0LjgxMiAxMi44NDggMi42NjQgMTkuNTEyLS41NTEgMS43MS0xLjk2NSA0LjUxMi0zLjAyOCA1Ljk4OC0zLjMyNCA0LjY0OS04LjMzNSA3Ljg4Ny0xNC4wNDYgOS4wMzUtMS4yMzkuMjUtMS43NzguMjktNC4yOS4yOS0yLjY4Ny0uMDEyLTIuOTc2LS4wNC00LjQzNy0uMzY0LS44NjMtLjE4Ny0xLjctLjM3NS0xLjg3NS0uMzk4LS4zLS4wNjMtLjYzNy42MjUtMTAuNTYzIDIxLjI2MS01LjYzNiAxMS43MjctMTAuMjYxIDIxLjM4Ny0xMC4yODkgMjEuNDg5LS4wMjMuMDg2LjQ2NS40MzcgMS4xMjUuODEyIDMuMjI3IDEuODM2IDUuOTc3IDMuOTEgOC43NjYgNi42MjUgMi4xNDkgMi4wOTggMy40NSAzLjYxNCA1LjA3NCA1Ljg2NCAzLjI4NSA0LjU4NSA1Ljg2NCAxMC40NDkgNi45NzMgMTUuODc1LjE0LjY0OC4yNzcgMS4wMjMuMzkgMS4wMjMuMTI2IDAgNjkuOTEtMTcuMTY0IDcwLjM0OC0xNy4zMTMuMDktLjAyMy4wNC0uNDE0LS4xNDgtMS4xNjQtLjg1Mi0zLjM1OS0uNjY0LTcuMDYyLjUzNS0xMC4yNzMgMi4xMjUtNS43NSA2Ljg0LTkuODYzIDEyLjkwMi0xMS4yNjIgMS4zODctLjMyNCA0LjU3NS0uNDQ5IDYuMDM1LS4yNSA4LjAyOCAxLjEzNyAxNC4xNjUgNy4zMjUgMTUuMjM5IDE1LjM0OC4xOTkgMS41LjA3NCA0LjUzOS0uMjUgNS45MTQtLjc3NCAzLjM1Mi0yLjM5OSA2LjM1Mi00LjcxMSA4LjY3Ni0yLjY2NCAyLjY4Ny01LjY2NCA0LjI3My05LjQ4OCA1LjAzNS0xLjY3Ni4zMjgtNC44MzYuMy02LjUyNC0uMDYzLTIuNDI2LS41MjMtNC41MjctMS40MjEtNi42MDEtMi44MTItMS40ODktMS0zLjY2NS0zLjIxMS00LjU2My00LjYyNS0uNDg4LS43NS0uNzYyLTEuMDc0LS45MzgtMS4wNzQtLjEzNiAwLTE2LjI1IDMuOTQ5LTM1LjgxMiA4Ljc3N2wtMzUuNTYzIDguNzczLjA0IDMuMzc2Yy4wNDYgMy42ODctLjExNCA1Ljc1LS42NjUgOC43MjYtMS4zNjMgNy4zNi00LjU1IDE0LjE0OS05LjMyNCAxOS44MzYtMi4xODcgMi42MDItNS42MDEgNS43NS04LjAyNyA3LjM3NS0uNDUuMzEzLS44MzYuNjEzLS44MzYuNjY0IDAgLjA0NyAzLjMzNiA1LjY2IDcuMzk4IDEyLjQ3M2w3LjQxNSAxMi4zNzUuNjg3LS4wOThjMS4wNjMtLjE1MiAyLjcyNy0uMDkgMy42MjUuMTM3LjQ1LjExMyAxLjM1Mi40NiAyIC43ODUuOTg4LjQ4OCAxLjM2My43NjYgMi4yNSAxLjY1MiAxLjE4OCAxLjE4OCAxLjg3NSAyLjM0OCAyLjM4NyA0LjAxMi4zNTEgMS4xMjUuNDE0IDMuMzc1LjEyNSA0LjQ4OC0uODg3IDMuNDYxLTMuNDM4IDYuMDEyLTYuODk5IDYuODk5LTEuMTEzLjI4OS0zLjM2My4yMjYtNC40ODgtLjEyNS0xLjY2NC0uNTEyLTIuODI0LTEuMi00LjAxMi0yLjM4Ny0uODg2LS44ODctMS4xNjQtMS4yNjItMS42NTItMi4yNS0uNzk3LTEuNjEzLTEuMDEyLTIuNTEyLTEuMDEyLTQuMTg4IDAtMi42MzYuODc1LTQuNjY0IDIuODEzLTYuNTc0bC44NTEtLjgxMi03LjI2MS0xMi4xMjVjLTMuOTg5LTYuNjY0LTcuMzAxLTEyLjE2NC03LjM2NC0xMi4yMjctLjA2Mi0uMDc0LS44NzUuMjUtMi4wNzQuODQtNC4wNzggMS45NjEtNy43NzcgMy4xNDktMTEuOTc3IDMuODI0LTQuNjM2LjczOS05Ljg4Ni43LTE0LjQzNy0uMTEzLTMuODEzLS42ODgtNy45Ni0yLjA0LTExLjExMy0zLjYyNWwtMS0uNS0yLjU1MSAzLjY2NGMtMS4zOTggMi03LjMxMyAxMC40NzMtMTMuMTM3IDE4LjgyNGwtMTAuNTc0IDE1LjE2Ljc2Mi44NGM0LjMxMiA0LjcyMyA2LjcxNSA5LjUgNy43MzggMTUuNDUuMzM2IDEuOTI1LjQ2IDUuMzYzLjI2MiA3LjM3NS0xLjE4OCAxMi4xNjQtOS44MjUgMjIuMzEyLTIxLjYzNyAyNS4zODYtMi4yMzguNTktNC4wMjMuODEzLTYuNjg4Ljg2My0xLjM3NC4wMjQtMy0uMDExLTMuNjI0LS4wNzR6bTAgMCIvPjwvc3ZnPg==', + body: data.payload.time + } + ).then(r => console.log('showed notification')); + }); + eventSource.addEventListener('proxy.keep_alive', (event) => { + console.log('Received keep alive: ', event); + }); + } + } + + /** + * @param controller ReadableStreamDefaultController + * @private + */ + _sendProxyKeepAlive(controller) { + controller.enqueue( + new TextEncoder().encode( + 'event: proxy.keep_alive\n' + + 'data: ' + JSON.stringify({ + time: new Date().toISOString().split('.')[0] + '+00:00', + payload: 'keep-alive' + }) + '\n' + + 'id: -1\n' + + 'retry: 500\n\n' + ) + ); + } + + /** + * @param url URL + * @param clientId string + * @returns {ReadableStream} + */ + getProxyStream(url, clientId) { + if (!(url in this._connections)) { + // FIXME: Firefox doesn't support EventSource in a service worker context because they are "special", add polyfill for firefox + this._connections[url] = new EventSource(url); + // register event listeners onto object + this._listenToEventSource(this._connections[url]); + } + if (!(clientId in this._proxyStreams)) { + let _this = this; + this._proxyStreams[clientId] = new ReadableStream({ + start(controller) { + this.interval = setInterval(() => { + _this._sendProxyKeepAlive(controller); + }, 10000); + }, + cancel() { + clearInterval(this.interval); + delete _this._proxyStreams[clientId]; + + // need to use the Object.keys property as array.length won't return the count as we're in an async state + if (Object.keys(_this._proxyStreams).length === 0) { + // there are no remaining tabs opened up, closing main connection if not already closed by EventSource.CLOSED event + if (_this._connections[url]) { + _this._connections[url].close(); + delete _this._connections[url]; + } + } + } + }); + } + return this._proxyStreams[clientId]; + } +} + +self.addEventListener('install', (/** ExtendableEvent event */ event) => { + self.console.log('Install event triggered.'); + + self.worker = new Worker(self); +}); +self.addEventListener('activate', (/** ExtendableEvent event */ event) => { + self.console.log('Activate event triggered.'); + self.clients.claim().then(r => self.console.log('clients claimed!')); + + self.worker._load(); +}); + +self.addEventListener('fetch', (/** FetchEvent event */ event) => { + const request = event.request; + + // only intercept SSE requests + if (request.headers.get('Accept') === 'text/event-stream') { + const url = new URL(request.url); + if (url.pathname.trim() === '/icingaweb2/notifications/daemon') { + // SSE request towards the daemon. This intercepts the requests and returns a dummy stream + const stream = self.worker.getProxyStream(url, event.clientId); + event.respondWith(new Response( + stream, + { + headers: { + 'Content-Type': 'text/event-stream; charset=utf-8', + 'Transfer-Encoding': 'chunked', + 'Connection': 'keep-alive' + } + } + )); + } + } +}); diff --git a/public/js/notification.js b/public/js/notification.js new file mode 100644 index 000000000..b890e560c --- /dev/null +++ b/public/js/notification.js @@ -0,0 +1,109 @@ +(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); \ No newline at end of file diff --git a/run.php b/run.php new file mode 100644 index 000000000..45c1ab070 --- /dev/null +++ b/run.php @@ -0,0 +1,15 @@ +provideHook('authentication', 'SessionStorage', true); +$this->addRoute('static-worker-file', new Zend_Controller_Router_Route_Static( + 'icinga-notifications-worker.js', + [ + 'controller' => 'daemon', + 'action' => 'script', + 'module' => 'notifications' + ] +));