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