From 9b8d6bbb999f1d0612dd2231985e1a86815420cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?No=C3=A9=20Costa?= Date: Fri, 17 May 2024 18:29:05 +0200 Subject: [PATCH 01/26] feat(Model\Daemon): mappings for new database table `browser_session` --- .../Notifications/Model/BrowserSession.php | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 library/Notifications/Model/BrowserSession.php 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'])); + } +} From 7592ca2338b993aa9a3fa892e05e85f5675daa92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?No=C3=A9=20Costa?= Date: Fri, 17 May 2024 18:29:07 +0200 Subject: [PATCH 02/26] feat: introduce daemon backend, daemon data classes --- application/clicommands/DaemonCommand.php | 16 + library/Notifications/Daemon/Daemon.php | 363 +++++++++++++ library/Notifications/Daemon/Sender.php | 158 ++++++ library/Notifications/Daemon/Server.php | 487 ++++++++++++++++++ .../Notifications/Model/Daemon/Connection.php | 167 ++++++ library/Notifications/Model/Daemon/Event.php | 109 ++++ .../Model/Daemon/EventIdentifier.php | 16 + library/Notifications/Model/Daemon/User.php | 40 ++ 8 files changed, 1356 insertions(+) create mode 100644 application/clicommands/DaemonCommand.php create mode 100644 library/Notifications/Daemon/Daemon.php create mode 100644 library/Notifications/Daemon/Sender.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/User.php diff --git a/application/clicommands/DaemonCommand.php b/application/clicommands/DaemonCommand.php new file mode 100644 index 00000000..718c0b02 --- /dev/null +++ b/application/clicommands/DaemonCommand.php @@ -0,0 +1,16 @@ +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()) + ->filter(Filter::greaterThan('id', $this->lastIncidentId)) + ->filter(Filter::equal('type', 'notified')) + ->orderBy('id', 'ASC') + ->with(['incident', 'incident.object']); + /** @var array> $connections */ + $connections = $this->server->getMatchedConnections(); + + /** @var IncidentHistory $notification */ + foreach ($notifications as $notification) { + if (isset($connections[$notification->contact_id])) { + /** @var Incident $incident */ + $incident = $notification->incident; + + $tags = null; + /** @var ObjectIdTag $tag */ + foreach ($incident->object->object_id_tag as $tag) { + $tags[] = $tag; + } + + if ($tags !== null) { + $host = $service = $message = ''; + + foreach ($tags as $tag) { + switch ($tag->tag) { + case 'host': + $host = $tag->value; + $message = "Host: " . $host; + + break; + case 'service': + $service = $tag->value; + $message .= ($message === '' ? "Service: " : " | Service: ") . $service; + + break; + } + } + + self::$logger::warning(self::PREFIX . $message); + + // reformat notification time + $time = $notification->time; + $time->setTimezone(new DateTimeZone('UTC')); + $time = $time->format(DateTimeInterface::RFC3339_EXTENDED); + + $event = new Event( + EventIdentifier::ICINGA2_NOTIFICATION, + $notification->contact_id, + (object) [ + 'incident_id' => $notification->incident_id, + 'event_id' => $notification->event_id, + 'host' => $host, + 'service' => $service, + 'time' => $time, + 'severity' => $incident->severity + ] + ); + + $this->emit(EventIdentifier::ICINGA2_NOTIFICATION, [$event]); + + ++$numOfNotifications; + } + } + + $this->lastIncidentId = $notification->id; + } + + 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..eb015d39 --- /dev/null +++ b/library/Notifications/Daemon/Server.php @@ -0,0 +1,487 @@ + 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); + }); + // subscribe to socket events + $this->socket->on('connection', function (ConnectionInterface $connection) { + $this->onSocketConnection($connection); + }); + $this->socket->on('error', function (Exception $error) { + self::$logger::error(self::PREFIX . "received an error on the socket: " . $error->getMessage()); + }); + // 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(); + + 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; + } + + /** + * 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" + ], + '' + ); + } + + // 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/Daemon/Connection.php b/library/Notifications/Model/Daemon/Connection.php new file mode 100644 index 00000000..43b13319 --- /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 = substr( + $raw, + strpos($raw, '[') + 1, + strpos($raw, ']') - (strpos($raw, '[') + 1) + ); + if (! $host) { + return false; + } + + if (strpos($host, '.')) { + // it's an IPv4, stripping empty IPv6 tags + $parsed->host = substr($host, strrpos($host, ':') + 1); + } else { + $parsed->host = $host; + } + + // port + $port = substr($raw, strpos($raw, ']') + 2); + if (! $port) { + return false; + } + + $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..82e460e7 --- /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; + } +} From 8cac0932377c184d451774a3ec2944b63870e545 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?No=C3=A9=20Costa?= Date: Fri, 17 May 2024 18:29:09 +0200 Subject: [PATCH 03/26] refactor(Model): type-hint contact identifier --- library/Notifications/Model/Contact.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/library/Notifications/Model/Contact.php b/library/Notifications/Model/Contact.php index 62d90071..83e2f095 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 From 84819f31957dbe144ee3f58112e2c75d23ff6a7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?No=C3=A9=20Costa?= Date: Fri, 17 May 2024 18:29:11 +0200 Subject: [PATCH 04/26] feat: add session storage hook --- .../ProvidedHook/SessionStorage.php | 154 ++++++++++++++++++ run.php | 3 +- 2 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 library/Notifications/ProvidedHook/SessionStorage.php diff --git a/library/Notifications/ProvidedHook/SessionStorage.php b/library/Notifications/ProvidedHook/SessionStorage.php new file mode 100644 index 00000000..4a8da9d3 --- /dev/null +++ b/library/Notifications/ProvidedHook/SessionStorage.php @@ -0,0 +1,154 @@ +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', $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 + ] + ); + $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 " . $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 " + . $user->getUsername() + . " and browser session " + . $this->session->getId() + ); + } + } +} diff --git a/run.php b/run.php index eac12b7c..e973dc9e 100644 --- a/run.php +++ b/run.php @@ -2,6 +2,7 @@ /* 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); From 4b432ff6b66d148398bc85a4c13a1c3491912ec9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?No=C3=A9=20Costa?= Date: Fri, 17 May 2024 18:29:13 +0200 Subject: [PATCH 05/26] feat: add notification banner icons --- public/img/icinga-notifications-critical.webp | Bin 0 -> 2140 bytes public/img/icinga-notifications-ok.webp | Bin 0 -> 2130 bytes public/img/icinga-notifications-unknown.webp | Bin 0 -> 2294 bytes public/img/icinga-notifications-warning.webp | Bin 0 -> 2278 bytes 4 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 public/img/icinga-notifications-critical.webp create mode 100644 public/img/icinga-notifications-ok.webp create mode 100644 public/img/icinga-notifications-unknown.webp create mode 100644 public/img/icinga-notifications-warning.webp diff --git a/public/img/icinga-notifications-critical.webp b/public/img/icinga-notifications-critical.webp new file mode 100644 index 0000000000000000000000000000000000000000..2ff909d3ee8553b1c0e0ca2ab0596521dd15ab56 GIT binary patch literal 2140 zcmV-i2&4B>Nk&Fg2mk}7A!&C5w`sQ zhiC%Kym{|kzli?JqHU{ctF^HQIfUG2ipV+k`xo~0jeX_p>wo|G-+xFW==KIu23#Xg zuEnI(h2*%FiejlDw_~4*a;b<@A|V*442rh@vI7HTEkz=8wYZlvto?-?$ z*CgYQ_N^vDvPWO&GUo7{`O_P1#Aogh`t-Q#X@EX+gzVF1pZr!I$^Oei?l{#(@weWn z%|)Iw?~wd)qet+zoJoaB>tM24&8dnG76+@)n6&dr zG2JWW_s)AH#Q1#jA}kxn6?*V^`ysp^HVh|y{MxcP7Snc$f8<%ky*(B}a&J?Tcgdzo zU61Wp7wc1OqtvPmvJ~6jRq+L~4R%OwJu6!wfVYbLjgmchOa4|V1#TI*RVS!7c%&xA zp1eOCD*eCf?jTMs@05%NGO*7^3g?Ecwv?TMd!)%-kvdCsSy?n)A}zB@R8kk+H{;4f z1Se&_qR@Ja>@g8L7JIjqu0Q2ib4#`5fr}%QJ4ddgk{;SYRacSSky}}gucT$e$C=Pq z5?CF!EOE}rv$$w!-iVY1ja^Zl5!OAlC(}#wrgQ@Tc}GjgX2BF}c-IX}8R>ki;oU!{ zqM6u3YNHL%6CD)Q+BE~yZ&g_8*r7Wu}p zES&1x5E8S~6M&4geV0YPPKCG~I%0NGNV33`&q&KnJ zlG|kAWe;9BkXobY1Ng=?kKQ{IVx;j5d`y7%r2V?1pc`y2!mm3Yymjo*Op}@<+U9sz zI0h?2q(%>(%Av`FsnRxhuvJoH2VX@R;jLqzW?H107mkfuqSQQh?9?`$=AmP&d@3`^ zKX`8~E+Ktl-A?vujmD^rCOetxoEJ_xQQ-K*WG4&n-lKmr)f)+mv?S9UO>Li?*iQ8U5}G?lWs%>V zpAH~IpDK}+>>6A~!x|xv)B2JZWJ7Tf%6JcU(2x2 zJ7~X3N|6~vxJ0zIOK}k4v`AQYEe{UbL&YZ=CY+@-Q1KfH6V7sWP_a$H|e!@ur*xOhqz0Y;VC4n5oqj(^L^7pca#iA)2 zK?L%$=%$*wkQFAJRp%XG>!-802QrjMQ^nErgi{B&+2c0AuXqtftwLq0tvVh^Oa<8C zc($UOO;GhTb0^hd_R%b?E!G?e>Q>)L%!H1zP=~#8h~9s74EPfgv_K(b-RT%Kd0Y(< zHOx6xqz(&!&@t_ZGT0=t9wI!{3AIuUd`vy9&L{-g)BeA6u2?1*i zDm;B5pv{hHHsL<8b}f#mhBht0n`iTeX`;`k7Fcq+TgZXobNm6-eM?RO@7eC+Pbiew z@nRS7qP>abrvrk?cDss#MFR54Wja$YxNC=50&HkFeX13meW)&TB;?ckY=@bH+Op#l zfgNU}fL+5Y&NQ4@a`CkRvhKP^19`c?rjYKM?>9DF^9#-d45Y__<;zb^bP1I}(w%$8 zp(Ezx)SmqLGd72vQjt)otINL4ZA2s#Qn|LatZu_~3iz;xSxcEmnN0!eX$LL$z7xTj zXU+{jI{@8V)tXeK6jO$BzBeqSfg_!UNxvNfP!w%?ZlukaJd%&RePd$EiL-2C$|s_m zHNKr2Ep{Dwo|GuL-_!uWtY#ycZGx literal 0 HcmV?d00001 diff --git a/public/img/icinga-notifications-ok.webp b/public/img/icinga-notifications-ok.webp new file mode 100644 index 0000000000000000000000000000000000000000..c1fd49dcdad25e261f165735a4665625d9addf00 GIT binary patch literal 2130 zcmV-Y2(9;0Nk&FW2mk5TTjfaBZzY&Gmhfa0I4{tX)v+lNLDnE*l{%1=^(V@n9k|6 ztme2@bV4FIwo4#gOgluH*BsvqKrypYs^%y!ff+~r0#H#`0yB>K1)!p?1ZEud3qVC( zNtkiep93=LN`4y_UcW-bwkLt74U&!GZX2Ujr!IYc0ifqU*qU>#nU zL!|B`AP(@!0BdI?unMt@0hBWdNQGEEIZ2eRl)#ET+HA5kN#^;b|2RJhB6nl~D1XQV z$7$_7zUW8i0G3Xaz>Iy_+(&;Zea8j$NEGh~NE-(hfbvC2ICcO{OraJW*ok)!48i;H>P|EMWLv>4(K&Vsuy~0F`}m z0rOeo0~!o+beJyyksm1&SV^UOml%FELYxCwc03~3x^!-9ARQy#5s)^_YXN(guB{B* z8zoi*P`QTHSzK_P0R+o4w;mcuMvD0Y_Ai~<8c0X0B>S*-Ng2pTi{PLr-D%`xxXZCR zhm(mL%{DuO!FxQ?B^=GIYqr@LI>lh?u_9MQV^g!q&d@26T-NzODUWmkXER%xO-ASx zNiMlF9fZ_akvnKCP@9a0%g_&jmWgJXdT`J)|RIE_u8Qx02ITJ{RZ|^IWm{-FCHF z<+A26Puvnsugg3Y=%o2ZaVXbtTpqd1Q-MyJZxn~LbS-0MnWqAslx-A;_Cwds@-2yw_j<$ zUVjn}Z`pC@rS|Xj`=Kzk;+x-`ntx_Vrg`@X&(Wzj{l590byW=i1*t??a%T z7<*g${L2vF#MpDM{k#u>hoN%Sd-J>E@XkBjKzBKmkci9J>nd#o0*$6QQeFGu8+0<>Gi907`1 z6;rP~d4IqE=q8ZPffaYEJQ2S5lsmr(q7oqPyOa59KL5qFPx9Jud3cuE2(-yFF22f?!d*6f)B_kR*P1u$dJ9Jv2yI&ew? z)t)(U|Edk01lX}(4&1+Lb>N%>EB4EQ`&X?FoDv}R%Ypk>tqvUmlmmbM+uv>mJ8=K1 z)uD3^tk}M<{{YDMZ-zN=|EkrYBY}kNJ8(S#HIHwGIB@@})uB@oNZI#pX zf%{jj4xa>&?7|mZpy#a@#}3@TYIRsNa+9y}jcj_I11omodY|^etruMf?q9V!AR0L* z9H*H)QUb(o=ZX^Pc-xtqz7F2kPtoY=r z)xmHMvom%}Kfm3v;FGIX2g5l`uN~9RZv)~1pIo&%7z&t|j_K!rlze*C>R>2^1ynkw zudhz4S{)7q8xz;`_0=g=tHYsyozgXZebujObwH3MiF5k;s!!GGfGA+AbWUGi9jjU$ z5>smu_w@C?t7>&fl(4sPPhVfX_C>3M;-N)_2ef|nl>bW00#|sNqZ2U9NedoJ+YAj{*`_^yo`;HYEI;$<< zZ@qTS{#R2Yi<)hG-1_X}5n#>?tk}}eJ@z$^V?hI*Nw)Thw|?N!ENB4nfNg%_sUP;^ z!CWN$L?YSpgO|SE^SIXARhkgdbe_wa?SK5ecfNn0L%aI!M~~cm_dbXDl`r@F#{X|* I{$BJ00F&nuh5!Hn literal 0 HcmV?d00001 diff --git a/public/img/icinga-notifications-unknown.webp b/public/img/icinga-notifications-unknown.webp new file mode 100644 index 0000000000000000000000000000000000000000..64375bc48666f99fb965fc9d6a220baae49f7a65 GIT binary patch literal 2294 zcmVvBLBuAdZ`y>}uuy@tB9cAR~HV*|>$ z)bikBATF^lLtMxI{{q-|(tFZ--HqtKEXa22wz(H5D1xN&bU})Q^Zgb3e9Jx+`~2U3 z{O>bE@h&aX29*(??pLHn^UxnuwkCEs+K(BUWMRt2nQ8; zheaF;7k*-~PPj2;5XX+l`9tf(5kj+vo^Zv4!)xX*KRZMG$Q^_`y4235mYOEhtulU}ytjpz98XWe@=0pZg2%@X$=R?;GVSBnmc>|1+Zp^L z&l>LSp%9vTn~}UrHZ$ha#E*5c9>unud9^{7V*8URo*?^R2jrG>A$$@e4EY-+yWHBI z#lsPF%fO>z&8%NoV8pA{Hu#8aIoK&_kS)FN>K{$=lhuw_n`1L!c-Sd1z_ddic1k3e zHeq3>M2>buuI-s^_D7Q}8>gKRcS__b0n2SVD?GCqSd}?DICK>HYHRa-XlFKiMN_XV zQG4cekY>=fZNl5HlpL6;u`@mW(AfQZ-RY;J@hL~czHa(w+a}BnTP>~k3Z9XUPDQ&c z(UmP!d>U!btPxi11oxk@^$^XYGGEcrdXMZO5jz&UCsy-MJqck;bz9hS805~8PwkQx z&WEZ#w*5Z2m8B5!um^^$zuDSI{x^sDLgSI4UsK0L4O8UEXf6fuk-6(7i z(Y&@r7LZHO1x|2nssvwn&IT?$kXT&aVxil)TN0Bsijv5IoNSK zp<1f59Z7WbtWt`6jj(iFwbBFa1L^9lVR0R>-WjfOglu^?jL-v4YXD25?4}YLf!A3B`SC%ON@Om%&K9wso>b{UQ}@$h z+Sg61d*L^!DTOSL1MrQD9;%rS+9KdYjyvS&ZfVe(Z=&~L{ z$)ZqJhZ=WZ7XyVZbx+}%P0(~*b0^i|wB(0(2T5Lf2e3?J{Q!P7*1MX>HGUwnnTq&Jt776t4Zya<+A?<)&V7!phS{8#{b~i%67*xkx$vT6z z>;~)U34v~^kiRf5q_mf3!LB-g76RQgEMWiDec|#UZOlWwdWW7*t%*A5@>IlJclnTF z2#tb2P~AhllrpMR?7y@tmC8!M4_z|I^qFp*3Ey~n0*H@Jp=S_S3qPM+?t<6{gxXpq zzNAl$$hJy@;>WN}g9bR65G)B7A)5vXP;QJuu;CBnh{l4~SZ;)tPzfj9dDUw?@%pdh z@+W`(?vl@zh=f8Nb;>}yG-yaDWRyFrUe{4@IZyzMJRdRiK}`aI#*&|nw!`}SAhF-- zY}3bE%{PchDQ2K@zCVKp4Tp_VNPmC>JPBk^9ZVgZkbK&@^%)}M3{+|XdtOmHHdSCAZ^OwMw+QxOn#EUR;bSwWpig Qihcg?KmKC^Z@K4N0BHDTQvd(} literal 0 HcmV?d00001 diff --git a/public/img/icinga-notifications-warning.webp b/public/img/icinga-notifications-warning.webp new file mode 100644 index 0000000000000000000000000000000000000000..206b063a2a98da927bdc9d3c529a153407a0a498 GIT binary patch literal 2278 zcmV>c5(NL`AK1g4r$I0X zm^Msm;Gp0V?qA21lp26hBuP@Lh8sK}0{?>cz0H3JByDUyp8Q<^*!DMuXK$b>VT<_! znyscxW@Ab~vju2MK%4SQ&KC29EkIKyvoR%X0d2mp1!!lm;7GHe<^eK00UN$829o5G zflkT*0}J}6Fp$JRDjDdk3{c2LmRuxdpi{|!tm%J^?+4ztZPVMf*|zQ_jz|K@5|(Jg zd>MrqAS`&qpfrZ^PZ!7U)B3~;7;|OTX(weym3a{ zca^hI@8N+f)qmbPBJRJ{z|E`&aqeWqy?E$=bU&)dTC(DvtjSWc;@(^htxz4M0gSlUPXOKP3Sh*&egf!TR{$gK^%Fq% zx`Hs`UVjg$URMxOj&Jpl65R}EL6`$7x{4GRX%+xN%CY@Dp>5{f9GJyck)VC%8-X~s z3D++Dngg@gn&WEni$EOPx7R`WGzVs}Rls8A6M;Ckm5n=(=D;kv<~GP90&yR>^G5-* z_*{hcrR^n@lnD1kj-%SbYrN zwQm7DSlK)zuu3BZj5v_@uuEOT%K?yGr-pE<15v>Cv};%sa8tC~OyKyg2*bo$(mAYC zfQmMG0{7V_1Y`}WbC}-(Nq(V7A(9IJ9$|mlL7W44X!xEW^})NPgJBo(ih$vUd6q!# zgKtv@cb&wV04in>o%IW@GJqgF^Xt8Xw4<2cLjHqSO9#WwDo7q?K4}MeXAwM13V$|g zVDGW3?%`$X$7Y$`z~LQtbOBFuahqj!gT}<#tgFbEWFu*_$ZpV>q`9hcOGEDHIlRrJ zY!(@zF-dd9jYBJ>yNdk5Mu1*qgvMl;D{ficT1Zyh(Mxzdb)*+#LSr(_SA1H=QR?dW`*pfMTenjPx*bRiil?(Vr?>2*3ECumH@ zxn}pP<#amDRmEMN`ju>2J?6s%jWk|pj^zrTs|%0$FhL`Y7n);Q`Ia%W%7+OWDO+fc z?f1Uj@MNw%#FO%E+EjkM>Rd)s zHSkO|9nJMG(Zs1|-1x!R+(y%TzlKeKEzP#htwUc@sNu2vZ{QAkbJ;V~H zJ|y~8X&j%+SkmOdl3yQPnNuwBY9MaPuRj|1+gRecv?({%0_teuxvVKS?v2DlH1TpE zXv&RcE#kb6Ce|b+dB@F1BlB}Sv7X4F6*reg>NcL3=K~3ke6bdDim4pVQy{&tGZs(# zkGrDq z&#`n5Ou13#Y0@r9GsVqwV9Ly;xxVJhr|ZnmaZ~`rZC5Ivj>qHiG+(T^b&8~5ezEY} zVrStLM}$PTvcCdS-WiLB7F!Eu8$)k^cPujlR4uMv$IlWNvCwLOe;9G*5IqHfv=pBI z(Rgq!V`l=)Snl6_FsI0w10$BZHC~u))D#4&1l68=iJBBxvD}^U#Ox#H9+QFk-=_@y?X7Q4mN=ItJhr8!4bz@XiFlY`bft7Wp(^s7TiC&ItPG%Ig$WtfGAydiN8L9Q@ zZ$|2;E+8SPj9QH3dL6zwq$&wdCd7yE-HJ&ClqSgMGIRyxZrmNiWKN-bFKF)UqY3pk zaH*ioSsko8a05YiuJ%UmY4;^k4+X$-VY+L>LtC&;3w5W}Xoe_KH_9roB9|!EPtmV{K9OazX z!Qz3SI~TtjUtZ*x=WyK$8agPAM!BT=A?d^={dfnzI z%4|bb4a5-JuVtCfEuM_*dL1Z!BW7mr$w)ty4eJ*p{SYcGg|xNOcz8PO_NT8#{J9Ji zPlQa_`q2k(Pob&?BB-)fnl4=jigOV&w*FhE>_f#90aLdB(kXupR(0!a$PQ1Patan} za&%L Date: Fri, 17 May 2024 18:29:15 +0200 Subject: [PATCH 06/26] feat(JS): add service worker and JS module --- public/js/notifications-worker.js | 325 ++++++++++++++++++++++ public/js/notifications.js | 431 ++++++++++++++++++++++++++++++ 2 files changed, 756 insertions(+) create mode 100644 public/js/notifications-worker.js create mode 100644 public/js/notifications.js diff --git a/public/js/notifications-worker.js b/public/js/notifications-worker.js new file mode 100644 index 00000000..50ac1d31 --- /dev/null +++ b/public/js/notifications-worker.js @@ -0,0 +1,325 @@ +/* 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) => { + 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.trim() !== '/icingaweb2/notifications/daemon' + ) { + 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; + let title = ''; + let severity = 'unknown'; + + // match severity + switch (notification.payload.severity) { + case 'ok': + severity = 'ok'; + break; + case 'warning': + severity = 'warning'; + break; + case 'crit': + severity = 'critical'; + break; + } + + // build title + if (notification.payload.service !== '') { + title += "'" + notification.payload.service + "' on "; + } + title += "'" + notification.payload.host + "'"; + + void self.registration.showNotification( + title, + { + icon: data.baseUrl + '/img/notifications/icinga-notifications-' + severity + '.webp', + body: 'changed to severity ' + severity, + 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..38fbb99c --- /dev/null +++ b/public/js/notifications.js @@ -0,0 +1,431 @@ +/* Icinga Notifications Web | (c) 2024 Icinga GmbH | GPLv2 */ + +const VERSION = 1; + +(function (Icinga) { + + 'use strict'; + + Icinga.Behaviors = Icinga.Behaviors || {}; + + class Notification extends Icinga.EventListener { + prefix = '[Notification] - '; + 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(this.prefix + "module should not get loaded outside of a web context!"); + throw new Error("Attempted to initialize the 'Notification' module outside of a web context!"); + } + + // initialize object fields + this.icinga = icinga; + this.logger = icinga.logger; + this.toggleState = new Icinga.Storage.StorageAwareMap + .withStorage(Icinga.Storage.BehaviorStorage('notification'), 'toggle') + + // check for required API's + this.logger.debug(this.prefix + "checking for the required APIs and permissions"); + + let isValidated = true; + if (! 'ServiceWorker' in self) { + this.logger.error( + this.prefix + + "this browser does not support the 'Service Worker API' in the" + + " current context" + ); + isValidated = false; + } + if (! 'Navigator' in self) { + this.logger.error( + this.prefix + + "this browser does not support the 'Navigator API' in the" + + " current context" + ); + isValidated = false; + } + if (! 'Notification' in self) { + this.logger.error( + this.prefix + + "this browser does not support the 'Notification API' in the" + + " current context" + ); + isValidated = false; + } + if (! isValidated) { + // we only log the error and exit early as throwing would completely hang up the web application + this.logger.error("The 'Notification' module is missing some required API's"); + return; + } + + this.logger.debug(this.prefix + "spawned"); + this.load(); + } + + load() { + this.logger.debug(this.prefix + "loading"); + + // listen to render events on container for col1 (to inject notification toggle) + this.on('rendered', '#main > #col1.container', this.renderHandler, this); + + // listen to controller (service worker) changes + navigator.serviceWorker.addEventListener('controllerchange', (event) => { + this.logger.debug(this.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(this.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( + this.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( + this.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( + this.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(this.prefix + "loaded"); + }) + } + + unload() { + this.logger.debug(this.prefix + "unloading"); + + // disconnect EventSource if there's an active connection + this.closeEventStream(); + this.eventSource = null; + this.initialized = false; + + this.logger.debug(this.prefix + "unloaded"); + } + + reload() { + this.unload(); + this.load(); + } + + openEventStream() { + if (! this.hasNotificationPermission() || ! this.hasNotificationsEnabled()) { + this.logger.warn(this.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(this.prefix + "opening event source"); + this.eventSource = new EventSource( + icinga.config.baseUrl + '/notifications/daemon', + {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(this.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 (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); From 42cbb5b47a6ecf7dc8d3ed5c5ee008d68a9171a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?No=C3=A9=20Costa?= Date: Fri, 17 May 2024 18:29:17 +0200 Subject: [PATCH 07/26] feat(Controller): load logic and JS file routing --- application/controllers/DaemonController.php | 106 +++++++++++++++++++ configuration.php | 6 +- run.php | 15 +++ 3 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 application/controllers/DaemonController.php diff --git a/application/controllers/DaemonController.php b/application/controllers/DaemonController.php new file mode 100644 index 00000000..c11b08b7 --- /dev/null +++ b/application/controllers/DaemonController.php @@ -0,0 +1,106 @@ +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) { + 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/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/run.php b/run.php index e973dc9e..9e94cef8 100644 --- a/run.php +++ b/run.php @@ -6,3 +6,18 @@ $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' + ] + ) +); From 0ba0adbacbbf95b386eaf90f4de15cdd31aacce2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?No=C3=A9=20Costa?= Date: Fri, 17 May 2024 18:29:26 +0200 Subject: [PATCH 08/26] docs: add desktop notifications documentation --- public/js/doc/NOTIFICATIONS.md | 29 ++++++ public/js/doc/notifications-arch.puml | 29 ++++++ public/js/doc/notifications-arch.svg | 140 ++++++++++++++++++++++++++ public/js/module.js | 4 +- 4 files changed, 201 insertions(+), 1 deletion(-) create mode 100644 public/js/doc/NOTIFICATIONS.md create mode 100644 public/js/doc/notifications-arch.puml create mode 100644 public/js/doc/notifications-arch.svg 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"; From b28343c535e9eb76bbe96f1c0b8a2da3c9c4f703 Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Fri, 24 May 2024 15:58:17 +0200 Subject: [PATCH 09/26] js: Simplify constructor, split dependency checks and initial load --- public/js/notifications.js | 91 +++++++++++++++++--------------------- 1 file changed, 40 insertions(+), 51 deletions(-) diff --git a/public/js/notifications.js b/public/js/notifications.js index 38fbb99c..44db4f2e 100644 --- a/public/js/notifications.js +++ b/public/js/notifications.js @@ -6,10 +6,29 @@ const VERSION = 1; '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 { - prefix = '[Notification] - '; eventSource = null; toggleState = null; initialized = false; @@ -20,7 +39,7 @@ const VERSION = 1; // only allow to be instantiated in a web context if (! self instanceof Window) { - this.logger.error(this.prefix + "module should not get loaded outside of a web context!"); + 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!"); } @@ -30,59 +49,29 @@ const VERSION = 1; this.toggleState = new Icinga.Storage.StorageAwareMap .withStorage(Icinga.Storage.BehaviorStorage('notification'), 'toggle') - // check for required API's - this.logger.debug(this.prefix + "checking for the required APIs and permissions"); + // listen for events + this.on('icinga-init', null, this.onInit, this); + this.on('rendered', '#main > .container', this.renderHandler, this); - let isValidated = true; - if (! 'ServiceWorker' in self) { - this.logger.error( - this.prefix - + "this browser does not support the 'Service Worker API' in the" - + " current context" - ); - isValidated = false; - } - if (! 'Navigator' in self) { - this.logger.error( - this.prefix - + "this browser does not support the 'Navigator API' in the" - + " current context" - ); - isValidated = false; - } - if (! 'Notification' in self) { - this.logger.error( - this.prefix - + "this browser does not support the 'Notification API' in the" - + " current context" - ); - isValidated = false; - } - if (! isValidated) { - // we only log the error and exit early as throwing would completely hang up the web application - this.logger.error("The 'Notification' module is missing some required API's"); - return; - } + this.logger.debug(LOG_PREFIX + "spawned"); + } - this.logger.debug(this.prefix + "spawned"); - this.load(); + onInit(event) { + event.data.self.load(); } load() { - this.logger.debug(this.prefix + "loading"); - - // listen to render events on container for col1 (to inject notification toggle) - this.on('rendered', '#main > #col1.container', this.renderHandler, this); + this.logger.debug(LOG_PREFIX + "loading"); // listen to controller (service worker) changes navigator.serviceWorker.addEventListener('controllerchange', (event) => { - this.logger.debug(this.prefix + "new controller attached ", event.target.controller); + 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(this.prefix + "send handshake to controller"); + this.logger.debug(LOG_PREFIX + "send handshake to controller"); event.target.controller.postMessage( JSON.stringify({ command: 'handshake', @@ -103,7 +92,7 @@ const VERSION = 1; case 'handshake': if (data.status === 'outdated') { this.logger.debug( - this.prefix + LOG_PREFIX + "handshake got rejected as we're running an outdated script version" ); @@ -117,7 +106,7 @@ const VERSION = 1; this.allowedToOperate = false; } else { this.logger.debug( - this.prefix + LOG_PREFIX + "handshake got accepted by the controller" ); @@ -139,7 +128,7 @@ const VERSION = 1; if (! this.allowedToOperate) { // we are not allowed to open up connections, rejecting the request this.logger.debug( - this.prefix + LOG_PREFIX + "rejecting the request to open up an event-stream as this tab is not allowed" + " to (failed the handshake with the controller)" ); @@ -196,19 +185,19 @@ const VERSION = 1; } }) .finally(() => { - this.logger.debug(this.prefix + "loaded"); + this.logger.debug(LOG_PREFIX + "loaded"); }) } unload() { - this.logger.debug(this.prefix + "unloading"); + 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(this.prefix + "unloaded"); + this.logger.debug(LOG_PREFIX + "unloaded"); } reload() { @@ -218,7 +207,7 @@ const VERSION = 1; openEventStream() { if (! this.hasNotificationPermission() || ! this.hasNotificationsEnabled()) { - this.logger.warn(this.prefix + "denied opening event-stream as the notification permissions" + + this.logger.warn(LOG_PREFIX + "denied opening event-stream as the notification permissions" + " are missing or the notifications themselves disabled"); return; @@ -228,7 +217,7 @@ const VERSION = 1; this.closeEventStream(); try { - this.logger.debug(this.prefix + "opening event source"); + this.logger.debug(LOG_PREFIX + "opening event source"); this.eventSource = new EventSource( icinga.config.baseUrl + '/notifications/daemon', {withCredentials: true} @@ -253,7 +242,7 @@ const VERSION = 1; }); }); } catch (error) { - this.logger.error(this.prefix + `got an error while trying to open up an event-stream:`, error); + this.logger.error(LOG_PREFIX + `got an error while trying to open up an event-stream:`, error); } } From 386d03dc6ff632201b181d98cd00e523f4029cee Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Fri, 24 May 2024 15:59:34 +0200 Subject: [PATCH 10/26] js: Attempt to open an event stream during init --- public/js/notifications.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/public/js/notifications.js b/public/js/notifications.js index 44db4f2e..ec1200dc 100644 --- a/public/js/notifications.js +++ b/public/js/notifications.js @@ -267,6 +267,9 @@ const VERSION = 1; */ if (_this.initialized === false) { _this.initialized = true; + if (_this.allowedToOperate && _this.hasNotificationPermission() && _this.hasNotificationsEnabled()) { + _this.openEventStream(); + } } if (url.pathname !== _this.icinga.config.baseUrl + '/account') { From 666fbb77b42159f62dfa84d761b3cb4667662069 Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Mon, 27 May 2024 18:14:22 +0200 Subject: [PATCH 11/26] IncidentHistory: Add group by workaround --- library/Notifications/Model/IncidentHistory.php | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) 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); From 002e0bfbe56714796b4b0cfed35fb1e880485304 Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Mon, 27 May 2024 18:17:38 +0200 Subject: [PATCH 12/26] Daemon: Only fetch transmitted notifications --- library/Notifications/Daemon/Daemon.php | 1 + 1 file changed, 1 insertion(+) diff --git a/library/Notifications/Daemon/Daemon.php b/library/Notifications/Daemon/Daemon.php index ae885c48..6a3a563a 100644 --- a/library/Notifications/Daemon/Daemon.php +++ b/library/Notifications/Daemon/Daemon.php @@ -253,6 +253,7 @@ protected function processNotifications(): void $notifications = IncidentHistory::on(Database::get()) ->filter(Filter::greaterThan('id', $this->lastIncidentId)) ->filter(Filter::equal('type', 'notified')) + ->filter(Filter::equal('notification_state', 'sent')) ->orderBy('id', 'ASC') ->with(['incident', 'incident.object']); /** @var array> $connections */ From ea0dae94ec0e92ec18b34d59990e4b05eca5b99c Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Mon, 27 May 2024 18:20:58 +0200 Subject: [PATCH 13/26] js: Expect the server to render an object's name --- library/Notifications/Daemon/Daemon.php | 61 ++++++------------------- public/js/notifications-worker.js | 8 +--- 2 files changed, 15 insertions(+), 54 deletions(-) diff --git a/library/Notifications/Daemon/Daemon.php b/library/Notifications/Daemon/Daemon.php index 6a3a563a..b027e149 100644 --- a/library/Notifications/Daemon/Daemon.php +++ b/library/Notifications/Daemon/Daemon.php @@ -251,6 +251,7 @@ protected function processNotifications(): void // grab new notifications and the current connections $notifications = IncidentHistory::on(Database::get()) + ->withColumns(['incident.object.id_tags']) ->filter(Filter::greaterThan('id', $this->lastIncidentId)) ->filter(Filter::equal('type', 'notified')) ->filter(Filter::equal('notification_state', 'sent')) @@ -265,54 +266,20 @@ protected function processNotifications(): void /** @var Incident $incident */ $incident = $notification->incident; - $tags = null; - /** @var ObjectIdTag $tag */ - foreach ($incident->object->object_id_tag as $tag) { - $tags[] = $tag; - } + $event = new Event( + EventIdentifier::ICINGA2_NOTIFICATION, + $notification->contact_id, + (object) [ + 'incident_id' => $notification->incident_id, + 'event_id' => $notification->event_id, + 'severity' => $incident->severity, + 'title' => $incident->object->getName()->render(), + ] + ); - if ($tags !== null) { - $host = $service = $message = ''; - - foreach ($tags as $tag) { - switch ($tag->tag) { - case 'host': - $host = $tag->value; - $message = "Host: " . $host; - - break; - case 'service': - $service = $tag->value; - $message .= ($message === '' ? "Service: " : " | Service: ") . $service; - - break; - } - } - - self::$logger::warning(self::PREFIX . $message); - - // reformat notification time - $time = $notification->time; - $time->setTimezone(new DateTimeZone('UTC')); - $time = $time->format(DateTimeInterface::RFC3339_EXTENDED); - - $event = new Event( - EventIdentifier::ICINGA2_NOTIFICATION, - $notification->contact_id, - (object) [ - 'incident_id' => $notification->incident_id, - 'event_id' => $notification->event_id, - 'host' => $host, - 'service' => $service, - 'time' => $time, - 'severity' => $incident->severity - ] - ); - - $this->emit(EventIdentifier::ICINGA2_NOTIFICATION, [$event]); - - ++$numOfNotifications; - } + $this->emit(EventIdentifier::ICINGA2_NOTIFICATION, [$event]); + + ++$numOfNotifications; } $this->lastIncidentId = $notification->id; diff --git a/public/js/notifications-worker.js b/public/js/notifications-worker.js index 50ac1d31..e992e3cb 100644 --- a/public/js/notifications-worker.js +++ b/public/js/notifications-worker.js @@ -112,7 +112,7 @@ function processMessage(event) { */ if (('Notification' in self) && (self.Notification.permission === 'granted')) { const notification = data.notification; - let title = ''; + const title = notification.payload.title; let severity = 'unknown'; // match severity @@ -128,12 +128,6 @@ function processMessage(event) { break; } - // build title - if (notification.payload.service !== '') { - title += "'" + notification.payload.service + "' on "; - } - title += "'" + notification.payload.host + "'"; - void self.registration.showNotification( title, { From 92c7f596ec4c578143a481449a144530b23ad3b9 Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Mon, 27 May 2024 18:21:53 +0200 Subject: [PATCH 14/26] js: Expect the server to provide a notification message --- library/Notifications/Daemon/Daemon.php | 5 +++-- public/js/notifications-worker.js | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/library/Notifications/Daemon/Daemon.php b/library/Notifications/Daemon/Daemon.php index b027e149..5bc82ca6 100644 --- a/library/Notifications/Daemon/Daemon.php +++ b/library/Notifications/Daemon/Daemon.php @@ -251,12 +251,12 @@ protected function processNotifications(): void // grab new notifications and the current connections $notifications = IncidentHistory::on(Database::get()) + ->with(['event', 'incident', 'incident.object']) ->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') - ->with(['incident', 'incident.object']); + ->orderBy('id', 'ASC'); /** @var array> $connections */ $connections = $this->server->getMatchedConnections(); @@ -274,6 +274,7 @@ protected function processNotifications(): void 'event_id' => $notification->event_id, 'severity' => $incident->severity, 'title' => $incident->object->getName()->render(), + 'message' => $notification->event->message ] ); diff --git a/public/js/notifications-worker.js b/public/js/notifications-worker.js index e992e3cb..77e8fedb 100644 --- a/public/js/notifications-worker.js +++ b/public/js/notifications-worker.js @@ -113,6 +113,7 @@ function processMessage(event) { 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 @@ -132,7 +133,7 @@ function processMessage(event) { title, { icon: data.baseUrl + '/img/notifications/icinga-notifications-' + severity + '.webp', - body: 'changed to severity ' + severity, + body: message, data: { url: data.baseUrl From ec3dd10e2581defb21a5bdbbd089e46d12e540a8 Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Tue, 28 May 2024 09:25:19 +0200 Subject: [PATCH 15/26] DaemonCommand: Add documentation --- application/clicommands/DaemonCommand.php | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/application/clicommands/DaemonCommand.php b/application/clicommands/DaemonCommand.php index 718c0b02..a7d0f386 100644 --- a/application/clicommands/DaemonCommand.php +++ b/application/clicommands/DaemonCommand.php @@ -9,6 +9,20 @@ class DaemonCommand extends Command { + /** + * Run the notifications daemon + * + * This program allows clients to subscribe to notifications and receive them in real-time on the desktop. + * + * USAGE: + * + * icingacli notifications daemon run [OPTIONS] + * + * OPTIONS + * + * --verbose Enable verbose output + * --debug Enable debug output + */ public function runAction(): void { Daemon::get(); From 535aba7a66cd1ad8f12213412b5266e74ff1d0bd Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Tue, 28 May 2024 09:25:56 +0200 Subject: [PATCH 16/26] Event: Omit `id` property It's not used at the moment anyway --- library/Notifications/Model/Daemon/Event.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/library/Notifications/Model/Daemon/Event.php b/library/Notifications/Model/Daemon/Event.php index 82e460e7..6339885e 100644 --- a/library/Notifications/Model/Daemon/Event.php +++ b/library/Notifications/Model/Daemon/Event.php @@ -92,7 +92,7 @@ protected function compileMessage(): string $message = 'event: ' . $this->identifier . PHP_EOL; $message .= 'data: ' . Json::encode($payload) . PHP_EOL; - $message .= 'id: ' . ($this->getLastEventId() + 1) . PHP_EOL; + //$message .= 'id: ' . ($this->getLastEventId() + 1) . PHP_EOL; $message .= 'retry: ' . $this->reconnectInterval . PHP_EOL; // ending newline From 1f59884ff6ffde8614ee8141b82fb9e524815483 Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Tue, 28 May 2024 09:26:43 +0200 Subject: [PATCH 17/26] js: Add note that new workers cannot take over active ones --- public/js/notifications-worker.js | 1 + 1 file changed, 1 insertion(+) diff --git a/public/js/notifications-worker.js b/public/js/notifications-worker.js index 77e8fedb..ff1e3b2d 100644 --- a/public/js/notifications-worker.js +++ b/public/js/notifications-worker.js @@ -25,6 +25,7 @@ selfSW.addEventListener('activate', (event) => { 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) => { From 86c990eea23faa02cfcc1a3348220920d52cea42 Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Tue, 28 May 2024 09:30:31 +0200 Subject: [PATCH 18/26] Use a versioned route to subscribe to notification messages No further version handling is currently implemented. In the future, legacy versions should be supported to some extent. This requires changes to `Event`, and probably other classses. --- library/Notifications/Daemon/Server.php | 9 +++++++++ public/js/notifications-worker.js | 2 +- public/js/notifications.js | 2 +- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/library/Notifications/Daemon/Server.php b/library/Notifications/Daemon/Server.php index eb015d39..5fc93e5f 100644 --- a/library/Notifications/Daemon/Server.php +++ b/library/Notifications/Daemon/Server.php @@ -276,6 +276,15 @@ protected function handleRequest(ServerRequestInterface $request): Response ); } + $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) { diff --git a/public/js/notifications-worker.js b/public/js/notifications-worker.js index ff1e3b2d..289cbbd7 100644 --- a/public/js/notifications-worker.js +++ b/public/js/notifications-worker.js @@ -35,7 +35,7 @@ selfSW.addEventListener('fetch', (event) => { // only check dedicated event stream requests towards the daemon if ( ! request.headers.get('accept').startsWith('text/event-stream') - || url.pathname.trim() !== '/icingaweb2/notifications/daemon' + || url.pathname.match(/\/notifications\/v(\d+)\/subscribe$/) === null ) { return; } diff --git a/public/js/notifications.js b/public/js/notifications.js index ec1200dc..a50d9a98 100644 --- a/public/js/notifications.js +++ b/public/js/notifications.js @@ -219,7 +219,7 @@ const VERSION = 1; try { this.logger.debug(LOG_PREFIX + "opening event source"); this.eventSource = new EventSource( - icinga.config.baseUrl + '/notifications/daemon', + icinga.config.baseUrl + '/notifications/v1/subscribe', {withCredentials: true} ); this.eventSource.addEventListener('icinga2.notification', (event) => { From 0f789dcba29070b967b3eee1b0249ac2ecea4443 Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Tue, 28 May 2024 14:49:23 +0200 Subject: [PATCH 19/26] Introduce default systemd file for the background daemon --- config/systemd/icinga-notifications-web.service | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 config/systemd/icinga-notifications-web.service 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 From fae1346247c7fdec2c5ca12a3889963ebae6dadb Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Tue, 28 May 2024 14:49:49 +0200 Subject: [PATCH 20/26] doc: Document the desktop notification feature --- doc/06-Desktop-Notifications.md | 106 ++++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 doc/06-Desktop-Notifications.md 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 +``` From fb306e990a7eb3c8f25400b46a1089f206c6c80c Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Tue, 28 May 2024 15:10:11 +0200 Subject: [PATCH 21/26] daemon/script: Make absolutely sure to prevent traversal attacks --- application/controllers/DaemonController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/controllers/DaemonController.php b/application/controllers/DaemonController.php index c11b08b7..e30cb89e 100644 --- a/application/controllers/DaemonController.php +++ b/application/controllers/DaemonController.php @@ -60,7 +60,7 @@ public function scriptAction(): void ->getBaseDir() . '/public/js'; $filePath = realpath($root . DIRECTORY_SEPARATOR . 'notifications-' . $fileName . $extension); - if ($filePath === false) { + if ($filePath === false || substr($filePath, 0, strlen($root)) !== $root) { if ($fileName === 'undefined') { $this->httpNotFound(t("No file name submitted")); } From 5aa604c2bb5721dc9823c02eaafbcaecd6d6f009 Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Fri, 31 May 2024 16:48:26 +0200 Subject: [PATCH 22/26] Daemon: Let integrations render an notification's title --- library/Notifications/Daemon/Daemon.php | 45 +++++++++++++++---------- 1 file changed, 28 insertions(+), 17 deletions(-) diff --git a/library/Notifications/Daemon/Daemon.php b/library/Notifications/Daemon/Daemon.php index 5bc82ca6..374c23be 100644 --- a/library/Notifications/Daemon/Daemon.php +++ b/library/Notifications/Daemon/Daemon.php @@ -9,6 +9,7 @@ use Evenement\EventEmitter; use Icinga\Application\Logger; use Icinga\Module\Notifications\Common\Database; +use Icinga\Module\Notifications\Hook\ObjectsRendererHook; use Icinga\Module\Notifications\Model\BrowserSession; use Icinga\Module\Notifications\Model\Daemon\Connection; use Icinga\Module\Notifications\Model\Daemon\Event; @@ -251,7 +252,7 @@ protected function processNotifications(): void // grab new notifications and the current connections $notifications = IncidentHistory::on(Database::get()) - ->with(['event', 'incident', 'incident.object']) + ->with(['event', 'incident', 'incident.object', 'incident.object.source']) ->withColumns(['incident.object.id_tags']) ->filter(Filter::greaterThan('id', $this->lastIncidentId)) ->filter(Filter::equal('type', 'notified')) @@ -261,24 +262,11 @@ protected function processNotifications(): void $connections = $this->server->getMatchedConnections(); /** @var IncidentHistory $notification */ + $notificationsToProcess = []; foreach ($notifications as $notification) { if (isset($connections[$notification->contact_id])) { - /** @var Incident $incident */ - $incident = $notification->incident; - - $event = new Event( - EventIdentifier::ICINGA2_NOTIFICATION, - $notification->contact_id, - (object) [ - 'incident_id' => $notification->incident_id, - 'event_id' => $notification->event_id, - 'severity' => $incident->severity, - 'title' => $incident->object->getName()->render(), - 'message' => $notification->event->message - ] - ); - - $this->emit(EventIdentifier::ICINGA2_NOTIFICATION, [$event]); + ObjectsRendererHook::register($notification->incident->object); + $notificationsToProcess[] = $notification; ++$numOfNotifications; } @@ -286,6 +274,29 @@ protected function processNotifications(): void $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"); } From c18ad4133c90ecec94dae263748ea3b412d3660e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?No=C3=A9=20Costa?= Date: Mon, 3 Jun 2024 17:38:02 +0200 Subject: [PATCH 23/26] fix(Connection): connection gets properly mapped for both IPv4 and IPv6 socket bindings This fixes an issue which caused the URI mapping of the connections to fail when not using the IPv6 notation for both IPv4 and IPv6 addresses. It now supports both formats (IPv4 and IPv6 notation) and additionally resolves IPv4 addresses that are passed in the IPv6 notation. Tested with: native IPv4, native IPv4 in IPv6 notation, native IPv6 --- library/Notifications/Daemon/Server.php | 5 +++- .../Notifications/Model/Daemon/Connection.php | 30 +++++++++---------- 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/library/Notifications/Daemon/Server.php b/library/Notifications/Daemon/Server.php index 5fc93e5f..1dc3fe15 100644 --- a/library/Notifications/Daemon/Server.php +++ b/library/Notifications/Daemon/Server.php @@ -177,9 +177,12 @@ public function reload(): void 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($params['REMOTE_ADDR'] . ':' . $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; diff --git a/library/Notifications/Model/Daemon/Connection.php b/library/Notifications/Model/Daemon/Connection.php index 43b13319..f2fb050a 100644 --- a/library/Notifications/Model/Daemon/Connection.php +++ b/library/Notifications/Model/Daemon/Connection.php @@ -125,28 +125,28 @@ public static function parseHostAndPort(?string $address) ]; // host - $host = substr( - $raw, - strpos($raw, '[') + 1, - strpos($raw, ']') - (strpos($raw, '[') + 1) - ); - if (! $host) { + $host = parse_url($raw, PHP_URL_HOST); + $port = parse_url($raw, PHP_URL_PORT); + + if (! $host || ! $port) { return false; } - if (strpos($host, '.')) { - // it's an IPv4, stripping empty IPv6 tags - $parsed->host = substr($host, strrpos($host, ':') + 1); + 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; } - // port - $port = substr($raw, strpos($raw, ']') + 2); - if (! $port) { - return false; - } - $parsed->port = $port; $parsed->addr = $parsed->host . ':' . $parsed->port; From 3be1b33c27d013d8d83fa94c22a8b42eddc1ae6c Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Fri, 7 Jun 2024 11:55:29 +0200 Subject: [PATCH 24/26] Daemon\Server: Enhance error logging --- library/Notifications/Daemon/Server.php | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/library/Notifications/Daemon/Server.php b/library/Notifications/Daemon/Server.php index 1dc3fe15..f3716d3e 100644 --- a/library/Notifications/Daemon/Server.php +++ b/library/Notifications/Daemon/Server.php @@ -4,7 +4,6 @@ namespace Icinga\Module\Notifications\Daemon; -use Exception; use Fig\Http\Message\StatusCodeInterface; use Icinga\Application\Config; use Icinga\Application\Logger; @@ -20,6 +19,7 @@ use React\Http\Message\Response; use React\Socket\ConnectionInterface; use React\Socket\SocketServer; +use Throwable; class Server { @@ -106,12 +106,15 @@ public function load(): void $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 (Exception $error) { - self::$logger::error(self::PREFIX . "received an error on the socket: " . $error->getMessage()); + $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); From 0028789fa0364834c6951e9e0010f770f1416729 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?No=C3=A9=20Costa?= Date: Mon, 10 Jun 2024 13:54:21 +0200 Subject: [PATCH 25/26] refactor(SessionStorage): add username trimming --- library/Notifications/ProvidedHook/SessionStorage.php | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/library/Notifications/ProvidedHook/SessionStorage.php b/library/Notifications/ProvidedHook/SessionStorage.php index 4a8da9d3..3f018e32 100644 --- a/library/Notifications/ProvidedHook/SessionStorage.php +++ b/library/Notifications/ProvidedHook/SessionStorage.php @@ -69,7 +69,7 @@ public function onLogin(User $user): void // cleanup existing sessions from this user (only for the current browser) $userSessions = BrowserSession::on(Database::get()) - ->filter(Filter::equal('username', $user->getUsername())) + ->filter(Filter::equal('username', trim($user->getUsername()))) ->filter(Filter::equal('user_agent', $userAgent)) ->execute(); /** @var BrowserSession $session */ @@ -104,7 +104,10 @@ public function onLogin(User $user): void } Logger::debug( - "onLogin triggered for user " . $user->getUsername() . " and browser session " . $this->session->getId() + "onLogin triggered for user " + . trim($user->getUsername()) + . " and browser session " + . $this->session->getId() ); } } @@ -145,7 +148,7 @@ public function onLogout(User $user): void Logger::debug( "onLogout triggered for user " - . $user->getUsername() + . trim($user->getUsername()) . " and browser session " . $this->session->getId() ); From e9163b4bd25182a8ae55b5551c8e7524715de53e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?No=C3=A9=20Costa?= Date: Tue, 11 Jun 2024 10:22:38 +0200 Subject: [PATCH 26/26] feat(Session): provide `authenticated_at` programmatically --- library/Notifications/ProvidedHook/SessionStorage.php | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/library/Notifications/ProvidedHook/SessionStorage.php b/library/Notifications/ProvidedHook/SessionStorage.php index 3f018e32..87164fe4 100644 --- a/library/Notifications/ProvidedHook/SessionStorage.php +++ b/library/Notifications/ProvidedHook/SessionStorage.php @@ -4,6 +4,7 @@ namespace Icinga\Module\Notifications\ProvidedHook; +use DateTime; use Icinga\Application\Hook\AuthenticationHook; use Icinga\Application\Logger; use Icinga\Module\Notifications\Common\Database; @@ -90,9 +91,10 @@ public function onLogin(User $user): void $this->database->insert( 'browser_session', [ - 'php_session_id' => $this->session->getId(), - 'username' => trim($user->getUsername()), - 'user_agent' => $userAgent + 'php_session_id' => $this->session->getId(), + 'username' => trim($user->getUsername()), + 'user_agent' => $userAgent, + 'authenticated_at' => (new DateTime())->format('Uv') ] ); $this->database->commitTransaction();