diff --git a/application/clicommands/DaemonCommand.php b/application/clicommands/DaemonCommand.php index 49b9761ba..624caf857 100644 --- a/application/clicommands/DaemonCommand.php +++ b/application/clicommands/DaemonCommand.php @@ -9,6 +9,6 @@ class DaemonCommand extends Command { public function runAction(): void { - $daemon = Daemon::get(); + Daemon::get(); } } diff --git a/application/controllers/DaemonController.php b/application/controllers/DaemonController.php index cabb70e13..2316ad057 100644 --- a/application/controllers/DaemonController.php +++ b/application/controllers/DaemonController.php @@ -7,14 +7,14 @@ use ipl\Web\Compat\ViewRenderer; use Zend_Layout; -final class DaemonController extends CompatController +class DaemonController extends CompatController { protected $requiresAuthentication = false; public function init(): void { - /** - * override init function and disable Zend rendering as this controller provides no graphical output + /* + * Initialize the controller and disable view renderer and layout as this controller provides no graphical output */ /** @var ViewRenderer $viewRenderer */ $viewRenderer = $this->getHelper('viewRenderer'); @@ -32,11 +32,14 @@ public function scriptAction(): void $this->httpNotFound("File extension is missing."); case '.js': $mime = 'application/javascript'; + break; case '.js.map': $mime = 'application/json'; + break; } + $root = Icinga::app() ->getModuleManager() ->getModule('notifications') @@ -52,6 +55,7 @@ public function scriptAction(): void if ($this->_getParam('file') === null) { $this->httpNotFound("No file name submitted"); } + $this->httpNotFound( "'notifications-" . $this->_getParam('file') @@ -60,7 +64,6 @@ public function scriptAction(): void ); } else { $fileStat = stat($filePath); - $eTag = ''; if ($fileStat) { $eTag = sprintf( '%x-%x-%x', @@ -81,7 +84,14 @@ public function scriptAction(): void $this->getResponse() ->setHeader('ETag', $eTag) ->setHeader('Content-Type', $mime, true) - ->setHeader('Last-Modified', gmdate('D, d M Y H:i:s', $fileStat['mtime']) . ' GMT'); + ->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); @@ -90,8 +100,8 @@ public function scriptAction(): void } else { $this->httpNotFound( "'notifications-" - . $this->_getParam('file') - . $this->_getParam('extension') + . $this->params->get('file') + . $this->params->get('extension') . " could not be read" ); } diff --git a/library/Notifications/Daemon/Daemon.php b/library/Notifications/Daemon/Daemon.php index 416fd17a9..fae24cff8 100644 --- a/library/Notifications/Daemon/Daemon.php +++ b/library/Notifications/Daemon/Daemon.php @@ -1,4 +1,5 @@ -load(); } + /** + * Return the singleton instance of the Daemon class + * + * @return Daemon Singleton instance + */ public static function get(): Daemon { if (self::$instance === null) { @@ -86,7 +78,12 @@ public static function get(): Daemon return self::$instance; } - private function load(): void + /** + * Run the loading logic + * + * @return void + */ + protected function load(): void { self::$logger::debug(self::PREFIX . "loading"); @@ -95,19 +92,28 @@ private function load(): void $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"); } - public function unload(): void + /** + * 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(); @@ -122,7 +128,12 @@ public function unload(): void self::$logger::debug(self::PREFIX . "unloaded"); } - public function reload(): void + /** + * Run the reloading logic + * + * @return void + */ + protected function reload(): void { self::$logger::debug(self::PREFIX . "reloading"); @@ -132,7 +143,14 @@ public function reload(): void self::$logger::debug(self::PREFIX . "reloaded"); } - private function shutdown(bool $isManualShutdown = false): void + /** + * 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): void { self::$logger::info(self::PREFIX . "shutting down" . ($isManualShutdown ? " (manually triggered)" : "")); @@ -143,7 +161,14 @@ private function shutdown(bool $isManualShutdown = false): void exit(0); } - private function signalHandling(LoopInterface $loop): void + /** + * (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(); @@ -165,9 +190,15 @@ private function signalHandling(LoopInterface $loop): void $loop->addSignal(SIGTERM, $exitFunc); } - private function housekeeping(): void + /** + * 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; @@ -186,10 +217,16 @@ private function housekeeping(): void if ($deletions > 0) { self::$logger::info(self::PREFIX . "housekeeping cleaned " . $deletions . " stale browser sessions"); } + self::$logger::debug(self::PREFIX . "finished housekeeping job"); } - private function processNotifications(): void + /** + * Process new notifications (if there are any) + * + * @return void + */ + protected function processNotifications(): void { $numOfNotifications = 0; @@ -216,7 +253,7 @@ private function processNotifications(): void ->filter(Filter::greaterThan('id', $this->lastIncidentId)) ->filter(Filter::equal('type', 'notified')) ->orderBy('id', 'ASC'); - /** @var array> $connections */ + /** @var array> $connections */ $connections = $this->server->getMatchedConnections(); /** @var IncidentHistory $notification */ @@ -241,9 +278,11 @@ private function processNotifications(): void switch ($tag->tag) { case 'host': $host = $tag->value; + break; case 'service': $service = $tag->value; + break; } } @@ -260,20 +299,16 @@ private function processNotifications(): void $notification->contact_id, (object) [ 'incident_id' => $incident->incident_id, - 'event_id' => $incident->event_id, - 'host' => $host, - 'service' => $service, - 'time' => $time, - 'severity' => $incident->incident->severity - ], - // minus one as it's usually expected as an auto-incrementing id, we just want to pass it - // the actual id in this case - intval($notification->id - 1) + 'event_id' => $incident->event_id, + 'host' => $host, + 'service' => $service, + 'time' => $time, + 'severity' => $incident->incident->severity + ] ); $this->emit(EventIdentifier::ICINGA2_NOTIFICATION, array($event)); - // self::$logger::warning(self::PREFIX . @var_export($event, true)); ++$numOfNotifications; } } @@ -286,7 +321,17 @@ private function processNotifications(): void } } - private function run(): void + /** + * 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) { diff --git a/library/Notifications/Daemon/Sender.php b/library/Notifications/Daemon/Sender.php index 3955bb551..66c8b7e5c 100644 --- a/library/Notifications/Daemon/Sender.php +++ b/library/Notifications/Daemon/Sender.php @@ -7,36 +7,32 @@ use Icinga\Module\Notifications\Model\Daemon\Event; use Icinga\Module\Notifications\Model\Daemon\EventIdentifier; -final class Sender +class Sender { - private const PREFIX = '[daemon.sender] - '; + protected const PREFIX = '[daemon.sender] - '; - /** - * @var Sender $instance - */ + /** @var Sender $instance */ private static $instance; - /** - * @var Logger $logger - */ - private static $logger; + /** @var Logger $logger */ + protected static $logger; - /** - * @var Daemon $daemon - */ - private static $daemon; + /** @var Daemon $daemon */ + protected static $daemon; - /** - * @var Server $server - */ - private static $server; + /** @var Server $server */ + protected static $server; + + /** @var Closure $callback */ + protected $callback; /** - * @var Closure $callback + * Construct the singleton instance of the Sender class + * + * @param Daemon $daemon Reference to the Daemon instance + * @param Server $server Reference to the Server instance */ - private $callback; - - final private function __construct(Daemon &$daemon, Server &$server) + private function __construct(Daemon &$daemon, Server &$server) { self::$logger = Logger::getInstance(); self::$daemon =& $daemon; @@ -51,24 +47,72 @@ final private function __construct(Daemon &$daemon, Server &$server) $this->load(); } - final public function load(): void + /** + * 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"); } - final public function unload(): void + /** + * 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"); } /** - * @param Event $event + * 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 */ - private function processNotification(Event $event): void + protected function processNotification(Event $event): void { $connections = self::$server->getMatchedConnections(); @@ -83,8 +127,8 @@ private function processNotification(Event $event): void // 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) { + if ($c->getUserAgent() === $browserConnection->getUserAgent() + && $c !== $browserConnection) { // fallback connection for this browser exists, trying it again if ($c->sendEvent($event)) { $fallback = true; @@ -92,6 +136,7 @@ private function processNotification(Event $event): void } } } + if ($fallback === false) { self::$logger::error( self::PREFIX . @@ -100,17 +145,10 @@ private function processNotification(Event $event): void ); } } + $notifiedBrowsers[] = $browserConnection->getUserAgent(); } } } } - - final public static function get(Daemon &$daemon, Server &$server): Sender - { - if (self::$instance === null) { - self::$instance = new Sender($daemon, $server); - } - return self::$instance; - } } diff --git a/library/Notifications/Daemon/Server.php b/library/Notifications/Daemon/Server.php index a3eca5c96..916a3e502 100644 --- a/library/Notifications/Daemon/Server.php +++ b/library/Notifications/Daemon/Server.php @@ -10,6 +10,7 @@ use Icinga\Module\Notifications\Model\Contact; use Icinga\Module\Notifications\Model\Daemon\BrowserSession; use Icinga\Module\Notifications\Model\Daemon\Connection; +use ipl\Sql\Connection as SQLConnection; use ipl\Stdlib\Filter; use Psr\Http\Message\ServerRequestInterface; use React\EventLoop\LoopInterface; @@ -17,65 +18,60 @@ use React\Http\Message\Response; use React\Socket\ConnectionInterface; use React\Socket\SocketServer; -use stdClass; -final class Server +class Server { - private const PREFIX = '[daemon.server] - '; + protected const PREFIX = '[daemon.server] - '; - /** - * @var Server $instance - */ + /** @var Server $instance */ private static $instance; - /** - * @var LoopInterface $mainLoop - */ - private $mainLoop; + /** @var LoopInterface $mainLoop */ + protected $mainLoop; - /** - * @var Logger $logger - */ - private static $logger; + /** @var Logger $logger */ + protected static $logger; - /** - * @var SocketServer $socket - */ - private $socket; + /** @var SocketServer $socket */ + protected $socket; - /** - * @var HttpServer $http - */ - private $http; + /** @var HttpServer $http */ + protected $http; - /** - * @var array $connections - */ - private $connections; + /** @var array $connections */ + protected $connections; - /** - * @var \ipl\Sql\Connection $dbLink - */ - private $dbLink; + /** @var SQLConnection $dbLink */ + protected $dbLink; + + /** @var Config $config */ + protected $config; /** - * @var Config $config + * Construct the singleton instance of the Server class + * + * @param LoopInterface $mainLoop Reference to ReactPHP's main loop */ - private $config; - - private function __construct(LoopInterface $mainLoop) + private function __construct(LoopInterface &$mainLoop) { self::$logger = Logger::getInstance(); self::$logger::debug(self::PREFIX . "spawned"); - $this->mainLoop = $mainLoop; + $this->mainLoop =& $mainLoop; $this->dbLink = Database::get(); $this->config = Config::module('notifications'); $this->load(); } - public static function get(LoopInterface $mainLoop): Server + /** + * 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); @@ -84,10 +80,16 @@ public static function get(LoopInterface $mainLoop): Server self::$instance->mainLoop = $mainLoop; self::$instance->reload(); } + return self::$instance; } - private function load(): void + /** + * Run the loading logic + * + * @return void + */ + public function load(): void { self::$logger::debug(self::PREFIX . "loading"); @@ -107,7 +109,7 @@ private function load(): void $this->onSocketConnection($connection); }); $this->socket->on('error', function (Exception $error) { - $this->onSocketError($error); + self::$logger::error(self::PREFIX . "received an error on the socket: " . $error->getMessage()); }); // attach http server to socket $this->http->listen($this->socket); @@ -130,6 +132,11 @@ private function load(): void self::$logger::debug(self::PREFIX . "loaded"); } + /** + * Run the unloading logic + * + * @return void + */ public function unload(): void { self::$logger::debug(self::PREFIX . "unloading"); @@ -143,6 +150,11 @@ public function unload(): void self::$logger::debug(self::PREFIX . "unloaded"); } + /** + * Run the reloading logic + * + * @return void + */ public function reload(): void { self::$logger::debug(self::PREFIX . "reloading"); @@ -153,7 +165,14 @@ public function reload(): void self::$logger::debug(self::PREFIX . "reloaded"); } - private function mapRequestToConnection(ServerRequestInterface $request): ?Connection + /** + * 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'])) { @@ -164,26 +183,25 @@ private function mapRequestToConnection(ServerRequestInterface $request): ?Conne } } } + return null; } - private function onSocketConnection(ConnectionInterface $connection): void + /** + * 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('data', function ($data) use ($connection) { - $this->onConnectionData($connection, $data); - }); - $connection->on('end', function () use ($connection) { - $this->onConnectionEnd($connection); - }); - $connection->on('error', function ($error) use ($connection) { - $this->onConnectionError($connection, $error); - }); $connection->on('close', function () use ($connection) { - $this->onConnectionClose($connection); + $this->onSocketConnectionClose($connection); }); // keep track of this connection @@ -194,24 +212,15 @@ private function onSocketConnection(ConnectionInterface $connection): void } } - private function onSocketError(Exception $error): void - { - // TODO: ADD error handling - } - - private function onConnectionData(ConnectionInterface $connection, string $data): void - { - } - private function onConnectionEnd(ConnectionInterface $connection): void - { - } - - private function onConnectionError(ConnectionInterface $connection, Exception $error): void - { - } - - private function onConnectionClose(ConnectionInterface $connection): void + /** + * 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) { @@ -227,7 +236,14 @@ private function onConnectionClose(ConnectionInterface $connection): void } } - private function handleRequest(ServerRequestInterface $request): Response + /** + * 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); @@ -250,7 +266,7 @@ private function handleRequest(ServerRequestInterface $request): Response return new Response( StatusCodeInterface::STATUS_INTERNAL_SERVER_ERROR, [ - "Content-Type" => "text/plain", + "Content-Type" => "text/plain", "Cache-Control" => "no-cache" ], '' @@ -269,7 +285,7 @@ private function handleRequest(ServerRequestInterface $request): Response // see https://javascript.info/server-sent-events#reconnection StatusCodeInterface::STATUS_NO_CONTENT, [ - "Content-Type" => "text/plain", + "Content-Type" => "text/plain", "Cache-Control" => "no-cache" ], '' @@ -290,7 +306,7 @@ private function handleRequest(ServerRequestInterface $request): Response // see https://javascript.info/server-sent-events#reconnection StatusCodeInterface::STATUS_NO_CONTENT, [ - "Content-Type" => "text/plain", + "Content-Type" => "text/plain", "Cache-Control" => "no-cache" ], '' @@ -319,9 +335,9 @@ private function handleRequest(ServerRequestInterface $request): Response return new Response( StatusCodeInterface::STATUS_OK, [ - "Connection" => "keep-alive", - "Content-Type" => "text/event-stream; charset=utf-8", - "Cache-Control" => "no-cache", + "Connection" => "keep-alive", + "Content-Type" => "text/event-stream; charset=utf-8", + "Cache-Control" => "no-cache", "X-Accel-Buffering" => "no" ], $connection->getStream() @@ -332,72 +348,91 @@ private function handleRequest(ServerRequestInterface $request): Response * @param Connection $connection * @param array $cookies * @param array> $headers - * @return stdClass + * + * @return object{isValid: bool, php_session_id: ?string, user: ?string, user_agent: ?string} */ - private function authenticate(Connection $connection, array $cookies, array $headers): stdClass + protected function authenticate(Connection $connection, array $cookies, array $headers): object { - $data = new stdClass(); - - if (array_key_exists('Icingaweb2', $cookies)) { - // session id is supplied, check for the existence of a user-agent header as it's needed to calculate - // the 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'; - } + $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; - } + // 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; } } } } - // the request is invalid, return this result - $data->isValid = false; + // authentication failed return $data; } - private function keepalive(): void + /** + * 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); } } - private function matchContact(?string $username): ?int + /** + * 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: the matching needs to be properly rewritten once we decide about how we want to handle the contacts + * 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) { @@ -413,6 +448,8 @@ private function matchContact(?string $username): ?int } /** + * Return list of contacts and their current connections + * * @return array> */ public function getMatchedConnections(): array diff --git a/library/Notifications/Model/Contact.php b/library/Notifications/Model/Contact.php index b22bc1762..60fef12b4 100644 --- a/library/Notifications/Model/Contact.php +++ b/library/Notifications/Model/Contact.php @@ -24,9 +24,6 @@ public function getKeyName(): string return 'id'; } - /** - * @return array - */ public function getColumns(): array { return [ @@ -37,9 +34,6 @@ public function getColumns(): array ]; } - /** - * @return array - */ public function getColumnDefinitions(): array { return [ @@ -49,9 +43,6 @@ public function getColumnDefinitions(): array ]; } - /** - * @return array - */ public function getSearchColumns(): array { return ['full_name']; @@ -62,9 +53,6 @@ public function createBehaviors(Behaviors $behaviors): void $behaviors->add(new HasAddress()); } - /** - * @return array - */ public function getDefaultSort(): array { return ['full_name']; diff --git a/library/Notifications/Model/Daemon/BrowserSession.php b/library/Notifications/Model/Daemon/BrowserSession.php index d60b91b36..8d6b2b13a 100644 --- a/library/Notifications/Model/Daemon/BrowserSession.php +++ b/library/Notifications/Model/Daemon/BrowserSession.php @@ -20,9 +20,6 @@ public function getTableName(): string return 'browser_session'; } - /** - * @return array - */ public function getKeyName(): array { return [ @@ -42,22 +39,16 @@ public function getColumns(): array ]; } - /** - * @return array - */ public function getColumnDefinitions(): array { return [ - 'php_session_id' => t('PHP\'s Session Identifier'), - 'username' => t('Username'), - 'user_agent' => t('User-Agent'), + 'php_session_id' => t('PHP\'s Session Identifier'), + 'username' => t('Username'), + 'user_agent' => t('User-Agent'), 'authenticated_at' => t('Authenticated At') ]; } - /** - * @return array - */ public function getSearchColumns(): array { return [ diff --git a/library/Notifications/Model/Daemon/Connection.php b/library/Notifications/Model/Daemon/Connection.php index cd95e545c..cfe67c2bc 100644 --- a/library/Notifications/Model/Daemon/Connection.php +++ b/library/Notifications/Model/Daemon/Connection.php @@ -4,56 +4,47 @@ use React\Socket\ConnectionInterface; use React\Stream\ThroughStream; -use stdClass; -final class Connection +class Connection { - /** - * @var ConnectionInterface $connection - */ - private $connection; + /** @var ConnectionInterface $connection */ + protected $connection; - /** - * @var string $host - */ - private $host; + /** @var string $host */ + protected $host; - /** - * @var int $port - */ - private $port; + /** @var int $port */ + protected $port; - /** - * @var string $session - */ - private $session; + /** @var string $session */ + protected $session; - /** - * @var User $user - */ - private $user; + /** @var User $user */ + protected $user; - /** - * @var ThroughStream $stream - */ - private $stream; + /** @var ThroughStream $stream */ + protected $stream; + + /** @var string $userAgent */ + protected $userAgent; /** - * @var string $userAgent + * Construct an instance of the Connection class + * + * @param ConnectionInterface $connection Connection details */ - private $userAgent; - public function __construct(ConnectionInterface $connection) { $this->connection = $connection; + $this->host = ''; + $this->port = -1; if ($connection->getRemoteAddress() !== null) { $address = $this->parseHostAndPort($connection->getRemoteAddress()); - $this->host = $address->host; - $this->port = (int) $address->port; - } else { - $this->host = ''; - $this->port = -1; + if ($address) { + $this->host = $address->host; + $this->port = (int) $address->port; + } } $this->stream = new ThroughStream(); @@ -62,25 +53,6 @@ public function __construct(ConnectionInterface $connection) $this->userAgent = ''; } - public static function parseHostAndPort(string $address): stdClass - { - $raw = $address; - $combined = new stdClass(); - $combined->host = substr( - $raw, - strpos($raw, '[') + 1, - strpos($raw, ']') - (strpos($raw, '[') + 1) - ); - if (strpos($combined->host, '.')) { - // it's an IPv4, stripping empty IPv6 tags - $combined->host = substr($combined->host, strrpos($combined->host, ':') + 1); - } - $combined->port = substr($raw, strpos($raw, ']') + 2); - $combined->addr = $combined->host . ':' . $combined->port; - - return $combined; - } - public function getHost(): string { return $this->host; @@ -101,11 +73,6 @@ public function getSession(): ?string return $this->session; } - public function setSession(string $session): void - { - $this->session = $session; - } - public function getStream(): ThroughStream { return $this->stream; @@ -126,11 +93,71 @@ 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( diff --git a/library/Notifications/Model/Daemon/Event.php b/library/Notifications/Model/Daemon/Event.php index 16f1f6053..73b9843d9 100644 --- a/library/Notifications/Model/Daemon/Event.php +++ b/library/Notifications/Model/Daemon/Event.php @@ -4,40 +4,31 @@ use DateTime; use DateTimeInterface; +use Icinga\Exception\Json\JsonEncodeException; use Icinga\Util\Json; use stdClass; -final class Event +class Event { - /** - * @var string $identifier - */ - private $identifier; + /** @var string $identifier */ + protected $identifier; - /** - * @var stdClass $data - */ - private $data; + /** @var stdClass $data */ + protected $data; - /** - * @var DateTime $createdAt - */ - private $createdAt; + /** @var DateTime $createdAt */ + protected $createdAt; - /** - * @var int $reconnectInterval - */ - private $reconnectInterval; + /** @var int $reconnectInterval */ + protected $reconnectInterval; - /** - * @var int $lastEventId - */ - private $lastEventId; + /** @var int $lastEventId */ + protected $lastEventId; /** @var int $contact */ - private $contact; + protected $contact; - final public function __construct(string $identifier, int $contact, stdClass $data, int $lastEventId = 0) + public function __construct(string $identifier, int $contact, stdClass $data, int $lastEventId = 0) { $this->identifier = $identifier; $this->contact = $contact; @@ -45,49 +36,55 @@ final public function __construct(string $identifier, int $contact, stdClass $da $this->reconnectInterval = 3000; $this->lastEventId = $lastEventId; - // TODO: Replace with hrtime(true) once the lowest supported PHP version raises to 7.3 $this->createdAt = new DateTime(); } - final public function getIdentifier(): string + public function getIdentifier(): string { return $this->identifier; } - final public function getContact(): int + public function getContact(): int { return $this->contact; } - final public function getData(): stdClass + public function getData(): stdClass { return $this->data; } - final public function getCreatedAt(): string + public function getCreatedAt(): string { return $this->createdAt->format(DateTimeInterface::RFC3339_EXTENDED); } - final public function getReconnectInterval(): int + public function getReconnectInterval(): int { return $this->reconnectInterval; } - final public function setReconnectInterval(int $reconnectInterval): void + public function getLastEventId(): int { - $this->reconnectInterval = $reconnectInterval; + return $this->lastEventId; } - final public function getLastEventId(): int + public function setReconnectInterval(int $reconnectInterval): void { - return $this->lastEventId; + $this->reconnectInterval = $reconnectInterval; } - private function compileMessage(): string + /** + * 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(), + 'time' => $this->getCreatedAt(), 'payload' => $this->getData() ]; @@ -101,7 +98,7 @@ private function compileMessage(): string return $message; } - final public function __toString(): string + 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 index 13071eff5..e6506cd98 100644 --- a/library/Notifications/Model/Daemon/EventIdentifier.php +++ b/library/Notifications/Model/Daemon/EventIdentifier.php @@ -2,58 +2,11 @@ namespace Icinga\Module\Notifications\Model\Daemon; -use Exception; - /** - * TODO: Replace with proper Enum once the lowest supported PHP version raises to 8.1 + * TODO(nc): Replace with proper Enum once the lowest supported PHP version raises to 8.1 */ -abstract class EventIdentifier +final class EventIdentifier { - /** - * @throws Exception - */ - final private function __construct() - { - throw new Exception("This enum class can't be instantiated."); - } - - /** - * @throws Exception - * @param string $name - * @param array $arguments - */ - final public function __call(string $name, array $arguments): void - { - throw new Exception("This enum class can't be called."); - } - - /** - * @throws Exception - * @param string $name - * @param array $arguments - */ - final public static function __callStatic(string $name, array $arguments): void - { - throw new Exception("This enum class can't be statically called."); - } - - /** - * @throws Exception - */ - final public function __serialize(): array - { - throw new Exception("This enum class can't be serialized."); - } - - /** - * @throws Exception - * @param array $data - */ - final public function __unserialize(array $data): void - { - throw new Exception("This enum class can't be deserialized."); - } - /** * notifications */ diff --git a/library/Notifications/Model/Daemon/User.php b/library/Notifications/Model/Daemon/User.php index 28962d842..4457a5e0e 100644 --- a/library/Notifications/Model/Daemon/User.php +++ b/library/Notifications/Model/Daemon/User.php @@ -2,17 +2,13 @@ namespace Icinga\Module\Notifications\Model\Daemon; -final class User +class User { - /** - * @var ?string $username - */ - private $username; + /** @var ?string $username */ + protected $username; - /** - * @var ?int $contactId - */ - private $contactId; + /** @var ?int $contactId */ + protected $contactId; public function __construct() { @@ -25,14 +21,14 @@ public function getUsername(): ?string return $this->username; } - public function setUsername(string $username): void + public function getContactId(): ?int { - $this->username = $username; + return $this->contactId; } - public function getContactId(): ?int + public function setUsername(string $username): void { - return $this->contactId; + $this->username = $username; } public function setContactId(int $contactId): void diff --git a/library/Notifications/Model/Incident.php b/library/Notifications/Model/Incident.php index 03a3ba95f..d2689c855 100644 --- a/library/Notifications/Model/Incident.php +++ b/library/Notifications/Model/Incident.php @@ -42,10 +42,10 @@ public function getColumns(): array public function getColumnDefinitions(): array { return [ - 'object_id' => t('Object Id'), - 'started_at' => t('Started At'), + 'object_id' => t('Object Id'), + 'started_at' => t('Started At'), 'recovered_at' => t('Recovered At'), - 'severity' => t('Severity') + 'severity' => t('Severity') ]; } diff --git a/library/Notifications/Model/IncidentHistory.php b/library/Notifications/Model/IncidentHistory.php index 4525b9a4f..ef0799cb7 100644 --- a/library/Notifications/Model/IncidentHistory.php +++ b/library/Notifications/Model/IncidentHistory.php @@ -11,7 +11,6 @@ use ipl\Orm\Relations; /** - * * @property int $id * @property int $incident_id * @property int $event_id @@ -38,9 +37,6 @@ public function getKeyName(): string return 'id'; } - /** - * @return array - */ public function getColumns(): array { return [ @@ -62,9 +58,6 @@ public function getColumns(): array ]; } - /** - * @return array - */ public function getColumnDefinitions(): array { return [ @@ -90,9 +83,6 @@ public function createBehaviors(Behaviors $behaviors): void $behaviors->add(new MillisecondTimestamp(['time'])); } - /** - * @return array - */ public function getDefaultSort(): array { return ['incident_history.time desc']; diff --git a/library/Notifications/ProvidedHook/SessionStorage.php b/library/Notifications/ProvidedHook/SessionStorage.php index 534216566..94915e53b 100644 --- a/library/Notifications/ProvidedHook/SessionStorage.php +++ b/library/Notifications/ProvidedHook/SessionStorage.php @@ -8,21 +8,18 @@ use Icinga\Module\Notifications\Model\Daemon\BrowserSession; use Icinga\User; use Icinga\Web\Session; +use Icinga\Web\UserAgent; use ipl\Sql\Connection; use ipl\Stdlib\Filter; use PDOException; class SessionStorage extends AuthenticationHook { - /** - * @var Session\Session $session - */ - private $session; + /** @var Session\Session $session */ + protected $session; - /** - * @var Connection $database - */ - private $database; + /** @var Connection $database */ + protected $database; public function __construct() { @@ -37,11 +34,11 @@ public function onLogin(User $user): void if ($this->session->exists()) { // user successfully authenticated - if ($_SERVER['HTTP_USER_AGENT']) { + $rawUserAgent = (new UserAgent())->getAgent(); + if ($rawUserAgent) { // limit user-agent to 4k chars - $userAgent = substr(trim($_SERVER['HTTP_USER_AGENT']), 0, 4096); - } - else { + $userAgent = substr(trim($rawUserAgent), 0, 4096); + } else { $userAgent = 'default'; } @@ -81,8 +78,8 @@ public function onLogin(User $user): void 'browser_session', [ 'php_session_id = ?' => $session->php_session_id, - 'username = ?' => trim($user->getUsername()), - 'user_agent = ?' => $userAgent + 'username = ?' => trim($user->getUsername()), + 'user_agent = ?' => $userAgent ] ); } @@ -94,8 +91,8 @@ public function onLogin(User $user): void 'browser_session', [ 'php_session_id' => $this->session->getId(), - 'username' => trim($user->getUsername()), - 'user_agent' => $userAgent + 'username' => trim($user->getUsername()), + 'user_agent' => $userAgent ] ); $this->database->commitTransaction(); @@ -105,6 +102,7 @@ public function onLogin(User $user): void ); $this->database->rollBackTransaction(); } + Logger::debug( "onLogin triggered for user " . $user->getUsername() . " and browser session " . $this->session->getId() ); @@ -119,11 +117,11 @@ public function onLogout(User $user): void $this->database->connect(); } - if ($_SERVER['HTTP_USER_AGENT']) { + $rawUserAgent = (new UserAgent())->getAgent(); + if ($rawUserAgent) { // limit user-agent to 4k chars - $userAgent = substr(trim($_SERVER['HTTP_USER_AGENT']), 0, 4096); - } - else { + $userAgent = substr(trim($rawUserAgent), 0, 4096); + } else { $userAgent = 'default'; } @@ -133,8 +131,8 @@ public function onLogout(User $user): void 'browser_session', [ 'php_session_id = ?' => $this->session->getId(), - 'username = ?' => trim($user->getUsername()), - 'user_agent = ?' => $userAgent + 'username = ?' => trim($user->getUsername()), + 'user_agent = ?' => $userAgent ] ); $this->database->commitTransaction(); diff --git a/public/js/notifications-worker.js b/public/js/notifications-worker.js index 95f49568e..9321f900c 100644 --- a/public/js/notifications-worker.js +++ b/public/js/notifications-worker.js @@ -4,7 +4,7 @@ const _PREFIX = '[notifications-worker] - '; const _SERVER_CONNECTIONS = {}; let _BASE_URL = ''; -if (!(self instanceof ServiceWorkerGlobalScope)) { +if (! (self instanceof ServiceWorkerGlobalScope)) { throw new Error("Tried loading 'notification-worker.js' in a context other than a Service Worker."); } @@ -32,8 +32,7 @@ selfSW.addEventListener('fetch', (event) => { if (Object.keys(_SERVER_CONNECTIONS).length < 2) { self.console.log(_PREFIX + `tab '${event.clientId}' requested event-stream.`); event.respondWith(this.injectMiddleware(request, event.clientId)); - } - else { + } else { self.console.log(_PREFIX + `event-stream request from tab '${event.clientId}' 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( @@ -48,10 +47,9 @@ selfSW.addEventListener('fetch', (event) => { }); selfSW.addEventListener('notificationclick', (event) => { event.notification.close(); - if (!('action' in event)) { + if (! ('action' in event)) { self.clients.openWindow(event.notification.data.url).then(); - } - else { + } else { switch (event.action) { case 'viewIncident': self.clients.openWindow(event.notification.data.url).then(); @@ -153,8 +151,7 @@ function processMessage(event) { } } }); - } - else { + } else { // notifications got disabled // closing existing streams self.clients.matchAll({ @@ -178,10 +175,10 @@ function processMessage(event) { async function injectMiddleware(request, clientId) { // define reference holders const controllers = { - writable: undefined, - readable: undefined, - signal: new AbortController() - }; + writable: undefined, + readable: undefined, + signal: new AbortController() + }; const streams = { writable: undefined, readable: undefined, @@ -211,7 +208,7 @@ async function injectMiddleware(request, clientId) { includeUncontrolled: false }).then((clients) => { for (const client of clients) { - if (!(client.id in _SERVER_CONNECTIONS) && client.id !== clientId) { + if (! (client.id in _SERVER_CONNECTIONS) && client.id !== clientId) { client.postMessage(JSON.stringify({ command: 'server_force_reconnect' })); diff --git a/public/js/notifications.js b/public/js/notifications.js index 7591a6140..1b36dd3e3 100644 --- a/public/js/notifications.js +++ b/public/js/notifications.js @@ -14,7 +14,7 @@ super(icinga); // only allow to be instantiated in a web context - if (!(self instanceof Window)) { + 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!"); } @@ -26,29 +26,27 @@ Icinga.Storage.BehaviorStorage('notification'), 'toggle' ) - // TODO: Remove once done testing - this._logger.logLevel = 'debug'; // check for required API's this._logger.debug(this._prefix + "checking for the required APIs and permissions."); let isValidated = true; - if (!('ServiceWorker' in self)) { + 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)) { + 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)) { + 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) { + 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; diff --git a/run.php b/run.php index 2a299a33f..6042b61dd 100644 --- a/run.php +++ b/run.php @@ -7,15 +7,18 @@ /** @var Module $this */ $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' - ] -)); +$this->addRoute( + 'static-file', + new Zend_Controller_Router_Route_Regex( + 'notifications-(.[^.]*)(\..*)', + [ + 'controller' => 'daemon', + 'action' => 'script', + 'module' => 'notifications' + ], + [ + 1 => 'file', + 2 => 'extension' + ] + ) +);