diff options
author | Simon Rettberg | 2021-06-25 16:21:17 +0200 |
---|---|---|
committer | Simon Rettberg | 2021-06-25 16:21:17 +0200 |
commit | 32f0677dbca9e3347b931c1d0105eb37aa57e90d (patch) | |
tree | ddad4562e7ee8439a24e2462d44614692bb71d14 | |
parent | Update .idea (diff) | |
download | slx-admin-32f0677dbca9e3347b931c1d0105eb37aa57e90d.tar.gz slx-admin-32f0677dbca9e3347b931c1d0105eb37aa57e90d.tar.xz slx-admin-32f0677dbca9e3347b931c1d0105eb37aa57e90d.zip |
[eventlog] Add event filtering and notification system
33 files changed, 2094 insertions, 107 deletions
diff --git a/inc/database.inc.php b/inc/database.inc.php index a55555b4..09006f3e 100644 --- a/inc/database.inc.php +++ b/inc/database.inc.php @@ -100,6 +100,41 @@ class Database } /** + * Fetch and group by first column. First column is key, value is a list of rows with remaining columns. + * [ + * col1 => [ + * [col2, col3], + * [col2, col3], + * ], + * ..., + * ] + * + * @return array|bool Associative array, first column is key, remaining columns are array values + */ + public static function queryGroupList($query, $args = array(), $ignoreError = null) + { + $res = self::simpleQuery($query, $args, $ignoreError); + if ($res === false) + return false; + return $res->fetchAll(PDO::FETCH_GROUP); + } + + /** + * Fetch and use first column as key of returned array. + * This is like queryGroup list, but it is assumed that the first column is unique, so + * the remaining columns won't be wrapped in another array. + * + * @return array|bool Associative array, first column is key, remaining columns are array values + */ + public static function queryIndexedList($query, $args = array(), $ignoreError = null) + { + $res = self::simpleQuery($query, $args, $ignoreError); + if ($res === false) + return false; + return $res->fetchAll(PDO::FETCH_GROUP | PDO::FETCH_UNIQUE); + } + + /** * Execute the given query and return the number of rows affected. * Mostly useful for UPDATEs or INSERTs * diff --git a/inc/eventlog.inc.php b/inc/eventlog.inc.php index 3ebb82a4..a29261b8 100644 --- a/inc/eventlog.inc.php +++ b/inc/eventlog.inc.php @@ -17,12 +17,14 @@ class EventLog error_log($message); return; } + $data = [ + 'type' => $type, + 'message' => $message, + 'details' => $details + ]; Database::exec("INSERT INTO eventlog (dateline, logtypeid, description, extra)" - . " VALUES (UNIX_TIMESTAMP(), :type, :message, :details)", array( - 'type' => $type, - 'message' => $message, - 'details' => $details - ), true); + . " VALUES (UNIX_TIMESTAMP(), :type, :message, :details)", $data, true); + self::applyFilterRules('#serverlog', $data); } public static function failure($message, $details = '') @@ -51,5 +53,16 @@ class EventLog return; Database::exec("TRUNCATE eventlog"); } - + + /** + * @param string $type the event. Will either be client state like ~poweron, ~runstate etc. or a client log type + * @param array $data A structured array containing event specific data that can be matched. + */ + public static function applyFilterRules(string $type, array $data) + { + if (!Module::isAvailable('eventlog')) + return; + FilterRuleProcessor::applyFilterRules($type, $data); + } + } diff --git a/inc/mailer.inc.php b/inc/mailer.inc.php new file mode 100644 index 00000000..942bfc75 --- /dev/null +++ b/inc/mailer.inc.php @@ -0,0 +1,178 @@ +<?php + +class Mailer +{ + + /** @var array|null */ + private static $configs = null; + + /** @var array */ + private $curlOptions; + + /** @var string|null */ + private $replyTo; + + /** @var string */ + private $errlog = ''; + + // $keys = array('host', 'port', 'ssl', 'senderAddress', 'replyTo', 'username', 'password', 'serverName'); + public function __construct(string $hosturi, bool $startTls, string $from, string $user, string $pass, string $replyTo = null) + { + $this->curlOptions = [ + CURLOPT_URL => $hosturi, + CURLOPT_USE_SSL => $startTls ? CURLUSESSL_ALL : CURLUSESSL_TRY, + CURLOPT_MAIL_FROM => $from, + CURLOPT_UPLOAD => 1, + CURLOPT_VERBOSE => 1, // XXX + ]; + if (!empty($user)) { + $this->curlOptions += [ + CURLOPT_LOGIN_OPTIONS => 'AUTH=NTLM;AUTH=DIGEST-MD5;AUTH=CRAM-MD5;AUTH=PLAIN;AUTH=LOGIN', + CURLOPT_USERNAME => $user, + CURLOPT_PASSWORD => $pass, + ]; + } + $this->replyTo = $replyTo; + } + + /** + * Send a mail to given list of recipients. + * @param string[] $rcpts Recipients + * @param string $subject Mail subject + * @param string $text Mail body + * @return int curl error code, CURLE_OK on success + */ + public function send(array $rcpts, string $subject, string $text): int + { + // Convert all line breaks to CRLF while trying to avoid introducing additional ones + $text = str_replace(["\r\n", "\r"], "\n", $text); + $text = str_replace("\n", "\r\n", $text); + $mail = ["Date: " . date('r')]; + foreach ($rcpts as $r) { + $mail[] = self::mimeEncode('To', $r); + } + $mail[] = self::mimeEncode('Content-Type', 'text/plain; charset="utf-8"'); + $mail[] = self::mimeEncode('Subject', $subject); + $mail[] = self::mimeEncode('From', $this->curlOptions[CURLOPT_MAIL_FROM]); + $mail[] = self::mimeEncode('Message-ID', '<' . Util::randomUuid() . '@rfcpedant.example.org>'); + if (!empty($this->replyTo)) { + $mail[] = self::mimeEncode('Reply-To', $this->replyTo); + } + + $mail = implode("\r\n", $mail) . "\r\n\r\n" . $text; + $c = curl_init(); + $pos = 0; + $this->curlOptions[CURLOPT_MAIL_RCPT] = array_map(function ($mail) { + return preg_replace('/\s.*$/', '', $mail); + }, $rcpts); + $this->curlOptions[CURLOPT_READFUNCTION] = function($ch, $fp, $len) use (&$pos, $mail) { + $txt = substr($mail, $pos, $len); + $pos += strlen($txt); + return $txt; + }; + $err = fopen('php://temp', 'w+'); + $this->curlOptions[CURLOPT_STDERR] = $err; + curl_setopt_array($c, $this->curlOptions); + curl_exec($c); + rewind($err); + $this->errlog = stream_get_contents($err); + fclose($err); + return curl_errno($c); + } + + public function getLog(): string + { + return $this->errlog; + } + + public static function queue(int $configid, array $rcpts, string $subject, string $text) + { + foreach ($rcpts as $rcpt) { + Database::exec("INSERT INTO mail_queue (rcpt, subject, body, dateline, configid) + VALUES (:rcpt, :subject, :body, UNIX_TIMESTAMP(), :config)", + ['rcpt' => $rcpt, 'subject' => $subject, 'body' => $text, 'config' => $configid]); + } + } + + public static function flushQueue() + { + $list = Database::queryGroupList("SELECT Concat(configid, rcpt, subject) AS keyval, + mailid, configid, rcpt, subject, body, dateline FROM mail_queue + WHERE nexttry <= UNIX_TIMESTAMP() LIMIT 20"); + $cutoff = time() - 43200; // 12h + $mailers = []; + foreach ($list as $mails) { + $delete = []; + $body = []; + foreach ($mails as $mail) { + $delete[] = $mail['mailid']; + if ($mail['dateline'] < $cutoff) { + EventLog::info("Dropping queued mail '{$mail['subject']}' for {$mail['rcpt']} as it's too old."); + continue; // Ignore + } + $body[] = ' *** ' . date('d.m.Y H:i:s', $mail['dateline']) . "\r\n" + . $mail['body']; + } + if (!empty($body) && isset($mail)) { + $body = implode("\r\n\r\n - - - - -\r\n\r\n", $body); + if (!isset($mailers[$mail['configid']])) { + $mailers[$mail['configid']] = self::instanceFromConfig($mail['configid']); + if ($mailers[$mail['configid']] === null) { + EventLog::failure("Invalid mailer config id: " . $mail['configid']); + } + } + if (($mailer = $mailers[$mail['configid']]) !== null) { + $ret = $mailer->send([$mail['rcpt']], $mail['subject'], $body); + if ($ret !== CURLE_OK) { + Database::exec("UPDATE mail_queue SET nexttry = UNIX_TIMESTAMP() + 7200 + WHERE mailid IN (:ids)", ['ids' => $delete]); + $delete = []; + EventLog::info("Error sending mail '{$mail['subject']}' for {$mail['rcpt']}.", + $mailer->getLog()); + } + } + } + // Now delete these, either sending succeeded or it's too old + if (!empty($delete)) { + Database::exec("DELETE FROM mail_queue WHERE mailid IN (:ids)", ['ids' => $delete]); + } + } + } + + private static function mimeEncode(string $field, string $string): string + { + if (preg_match('/[\r\n\x7f-\xff]/', $string)) { + return iconv_mime_encode($field, $string); + } + return "$field: $string"; + } + + private static function getConfig(int $configId): array + { + if (!is_array(self::$configs)) { + self::$configs = Database::queryIndexedList("SELECT configid, host, port, `ssl`, senderaddress, replyto, + username, password + FROM mail_config"); + } + return self::$configs[$configId] ?? []; + } + + /** + * @param int $configId + * @return Mailer|null + */ + public static function instanceFromConfig(int $configId) + { + $config = self::getConfig($configId); + if (empty($config)) + return null; + if ($config['ssl'] === 'IMPLICIT') { + $uri = "smtps://{$config['host']}:${config['port']}"; + } else { + $uri = "smtp://{$config['host']}:${config['port']}"; + } + return new Mailer($uri, $config['ssl'] === 'EXPLICIT', $config['senderaddress'], $config['username'], + $config['password'], $config['replyto']); + } + +}
\ No newline at end of file diff --git a/modules-available/eventlog/hooks/cron.inc.php b/modules-available/eventlog/hooks/cron.inc.php index 180bafd3..bf7ced17 100644 --- a/modules-available/eventlog/hooks/cron.inc.php +++ b/modules-available/eventlog/hooks/cron.inc.php @@ -2,4 +2,28 @@ if (mt_rand(1, 10) === 1) { Database::exec("DELETE FROM eventlog WHERE (UNIX_TIMESTAMP() - 86400 * 190) > dateline"); + // Keep at least 30 events or 7 days wirth of samples (whichever is more) + $types = Database::simpleQuery("SELECT type, Count(*) AS num, Min(dateline) as oldest + FROM `notification_sample` GROUP BY type"); + $cutoff = time() - 86400 * 7; + foreach ($types as $type) { + if ($type['num'] > 30 && $type['oldest'] < $cutoff) { + // This type has more than 30 and the oldest one is older than 7 days + // find out which one takes priority + $thisCutoff = $cutoff; + $find = Database::queryFirst("SELECT dateline FROM notification_sample + WHERE type = :type AND dateline + ORDER BY dateline DESC + LIMIT 29, 1", + ['type' => $type['type']]); + // The 30th entry is older than 7 days? Bump the cutoff dateline back to this date, + // so we keep at least 30 entries + if ($find !== false && $find['dateline'] < $thisCutoff) { + $thisCutoff = $find['dateline']; + } + Database::exec("DELETE FROM notification_sample + WHERE type = :type AND dateline < :dateline", + ['type' => $type['type'], 'dateline' => $thisCutoff]); + } + } } diff --git a/modules-available/eventlog/inc/filterruleprocessor.inc.php b/modules-available/eventlog/inc/filterruleprocessor.inc.php new file mode 100644 index 00000000..f4a84ce1 --- /dev/null +++ b/modules-available/eventlog/inc/filterruleprocessor.inc.php @@ -0,0 +1,264 @@ +<?php + +class FilterRuleProcessor +{ + + const MACHINE_COLUMNS = ['machineuuid', 'clientip', 'locationid', 'macaddr', 'firstseen', 'lastseen', 'logintime', + 'lastboot', 'state', 'realcores', 'mbram', 'kvmstate', 'cpumodel', 'systemmodel', 'id44mb', + 'live_memsize', 'live_tmpsize', 'live_swapsize', 'live_id45size', 'live_memfree', 'live_tmpfree', + 'live_swapfree', 'live_id45free', 'live_cpuload', 'live_cputemp', 'badsectors', 'hostname', 'currentrunmode', + 'currentsession', 'currentuser', 'notes', 'standbysem']; + + /* + * filter: + * [ + * [path, op, arg, result], + * ... + * ] + * + * path: slash separated path in multi-dimensional array. Supports "*" for everything on a level + * op: <, >, = etc, or "regex" + * arg: what to match via op + * result: if not empty, a string that's added to the fired event. use %1% for the matched value (simple ops), + * or %n% for capture group of regex. supports a couple suffixes like b for bytes, which will turn + * a byte value into a human readable string, eg %1b% will turn 1234567 into 1.18MiB. + * ts = timestamp, d = duration. + */ + + /** + * @param string $type the event. Will either be client state like ~poweron, ~runstate etc. or a client log type + * @param array $data A structured array containing event specific data that can be matched. + */ + public static function applyFilterRules(string $type, array $data) + { + static $lastType; + if ($lastType !== $type) { + $lastType = $type; + $exists = Database::queryFirst("SELECT type + FROM notification_sample + WHERE type = :type AND dateline > UNIX_TIMESTAMP() - 3600 LIMIT 1", + ['type' => $type]); + if ($exists === false) { + Database::exec("INSERT INTO notification_sample (type, dateline, data) + VALUES (:type, UNIX_TIMESTAMP(), :data)", [ + 'type' => $type, + 'data' => json_encode($data), + ]); + } + } + $types = explode('-', $type); + for ($i = 1; $i < count($types); ++$i) { + $types[$i] = $types[$i-1] . '-' . $types[$i]; + } + $res = Database::simpleQuery("SELECT ruleid, datafilter, subject, message + FROM notification_rule + WHERE type IN (:types)", + ['types' => $types]); + // Iterate over all matching filter rules + foreach ($res as $rule) { + if (empty($rule['message']) && empty($rule['subject'])) { + error_log('Filter rule with empty subject and message'); + continue; + } + $filters = json_decode($rule['datafilter'], true); + $globalMatch = true; + $values = []; + // Iterate over all filter-paths of this rule + foreach ($filters['list'] as $key => $filter) { + $index = $filter['index'] ?? $key; + $path = explode('/', $filter['path']); + // Get all items from $data that match the path + $items = self::get($path, $data); + if (empty($items)) { + // If empty, add an empty string to result, so != can match + $items[] = ''; + } + // Iterate over matches in $data - can be multiple if path contains '*' + foreach ($items as $item) { + $match = self::matches($item, $filter); + if ($match === null) + continue; + // Combine if multiple matches + $values[$index] = self::combine($values[$index] ?? [], $match); + } + if (!isset($values[$index])) { + $globalMatch = false; + break; + } + } + if ($globalMatch) { + self::fireEvent($rule, $values); + } + } + } + + /** + * Fire event for given rule, fill templates with data from $values + */ + private static function fireEvent(array $rule, array $values) + { + $ruleid = (int)$rule['ruleid']; + $subject = self::fillTemplate($rule['subject'], $values); + $message = self::fillTemplate($rule['message'], $values); + $ids = Database::queryColumnArray("SELECT transportid + FROM notification_rule_x_transport sfxb + WHERE sfxb.ruleid = :ruleid", ['ruleid' => $ruleid]); + $group = NotificationTransport::newGroup(...$ids); + $group->fire($subject, $message, $values); + } + + /** + * Get value at given path from assoc array. Calls itself recursively until path + * is just one element. Supports special '*' path element, which will return all + * items at the current level. For this reason, the return value is always an array. + * This function is "hacky", as it tries to figure out whether the current key is + * 1) the last path element and 2) matches a known column from the machines array. + * If there exists no such key at the current level, it will be checked whether + * machineuuid (preferred) or clientip exist at the current level, and if so, they + * will be used to query the missing data from the database. + * + * @param array $path array of all the path elements + * @param array $data data to wade through, first element of $path should be in it + * @return array all the matched values + */ + private static function get(array $path, array &$data): array + { + if (empty($path)) + return []; + $pathElement = array_shift($path); + // Get everything on this level + if ($pathElement === '*') { + if (!is_array($data)) + return []; + $return = []; + if (empty($path)) { + // End, everything needs to be primitive types + foreach ($data as $elem) { + if (!is_array($elem)) { + $return[] = $elem; + } + } + } else { + // Expected to go deeper + foreach ($data as $elem) { + if (is_array($elem)) { + $return = array_merge($return, self::get($path, $elem)); + } + } + } + return $return; + } + if (!array_key_exists($pathElement, $data) + && (isset($data['clientip']) || isset($data['machineuuid'])) && in_array($pathElement, self::MACHINE_COLUMNS)) { + if ($pathElement !== 'machineuuid' && isset($data['machineuuid'])) { + $row = Database::queryFirst("SELECT " . implode(',', self::MACHINE_COLUMNS) + . " FROM machine WHERE machineuuid = :uuid", ['uuid' => $data['machineuuid']]); + } elseif ($pathElement !== 'clientip' && isset($data['clientip'])) { + $row = Database::queryFirst("SELECT " . implode(',', self::MACHINE_COLUMNS) + . " FROM machine WHERE clientip = :ip ORDER BY lastseen DESC LIMIT 1", ['ip' => $data['clientip']]); + } else { + $row = false; + } + if ($row !== false) { + error_log('Additional client data fetched on the fly'); + $data += $row; + } + } + if (!array_key_exists($pathElement, $data)) + return []; + if (empty($path) && !is_array($data[$pathElement])) + return [$data[$pathElement]]; + if (!empty($path) && is_array($data[$pathElement])) + return self::get($path, $data[$pathElement]); + return []; // No match + } + + /** + * @param string $item item to match, string or number as string + * @param array $filter filter struct [op, arg, result] + * @return ?array null if op doesn't match, processed result otherwise + */ + private static function matches(string $item, array $filter) + { + $ok = false; + switch ($filter['op']) { + case '*': + $ok = true; + break; + case '>': + $ok = $item > $filter['arg']; + break; + case '>=': + $ok = $item >= $filter['arg']; + break; + case '<': + $ok = $item < $filter['arg']; + break; + case '<=': + $ok = $item <= $filter['arg']; + break; + case '=': + $ok = $item == $filter['arg']; + break; + case '!=': + $ok = $item != $filter['arg']; + break; + case 'regex': + $ok = (bool)preg_match($filter['arg'], $item, $out); + break; + default: + EventLog::warning("Invalid filter OP: {$filter['op']}"); + } + if (!$ok) // No match + return null; + // Fake $out array for simple matches + if ($filter['op'] !== 'regex') { + $out = [1 => $item]; + } + return $out; + } + + private static function fillTemplate(string $template, array $values): string + { + return preg_replace_callback('/%([0-9]+)\.([0-9]+|[a-z][a-z0-9]*)\.?([a-z]*)%/', function($m) use ($values) { + if (!isset($values[$m[1]])) + return '<invalid row index #' . $m[1] . '>'; + if (!isset($values[$m[1]][$m[2]])) + return '<invalid column index #' . $m[2] . ' for row #' . $m[1] . '>'; + $v = $values[$m[1]][$m[2]]; + $shift = 0; + switch ($m[3]) { + case 'gb': + $shift++; + case 'mb': + $shift++; + case 'kb': + $shift++; + case 'b': + return Util::readableFileSize($v, -1, $shift); + case 'ts': + return Util::prettyTime($v); + case 'd': + return Util::formatDuration($v); + case '': + break; + default: + $v .= '(unknown suffix ' . $m[3] . ')'; + } + return $v; + }, $template); + } + + private static function combine(array $a, array $b): array + { + foreach ($b as $k => $v) { + if (isset($a[$k])) { + $a[$k] .= ', ' . $v; + } else { + $a[$k] = $v; + } + } + return $a; + } + +}
\ No newline at end of file diff --git a/modules-available/eventlog/inc/notificationtransport.inc.php b/modules-available/eventlog/inc/notificationtransport.inc.php new file mode 100644 index 00000000..499f6371 --- /dev/null +++ b/modules-available/eventlog/inc/notificationtransport.inc.php @@ -0,0 +1,279 @@ +<?php + +abstract class NotificationTransport +{ + + public static function getInstance(array $data) + { + switch ($data['type'] ?? '') { + case 'mail': + return new MailNotificationTransport($data); + case 'irc': + return new IrcNotificationTransport($data); + case 'http': + return new HttpNotificationTransport($data); + case 'group': + return new GroupNotificationTransport($data); + } + error_log('Invalid Notification Transport: ' . ($data['type'] ?? '(unset)')); + return null; + } + + public static function newGroup(int ...$ids): GroupNotificationTransport + { + return new GroupNotificationTransport(['group-list' => $ids]); + } + + public abstract function __construct(array $data); + + public abstract function toString(): string; + + public abstract function fire(string $subject, string $message, array $raw): bool; + + public abstract function isValid(): bool; + +} + +class MailNotificationTransport extends NotificationTransport +{ + + /** @var int */ + private $mailConfigId; + + /** @var int[] */ + private $userIds; + + /** @var string */ + private $extraMails; + + public function __construct(array $data) + { + $this->mailConfigId = (int)($data['mail-config-id'] ?? 0); + $this->userIds = array_map(function ($i) { return (int)$i; }, $data['mail-users'] ?? []); + $this->extraMails = (string)($data['mail-extra-mails'] ?? ''); + } + + public function toString(): string + { + static $mailList = null; + if ($mailList === null) { + $mailList = Database::queryIndexedList("SELECT configid, host, senderaddress, replyto + FROM mail_config"); + } + $str = 'Via: ' . ($mailList[$this->mailConfigId]['host'] ?? '<none>') + . ' as ' . ($mailList[$this->mailConfigId]['senderaddress'] ?? $mailList[$this->mailConfigId]['replyto'] ?? '<none>'); + if (!empty($this->userIds)) { + $str .= ', Users: ' . count($this->userIds); + } + if (!empty($this->extraMails)) { + $str .= ', External: ' . substr_count($this->extraMails, '@'); + } + return $str; + } + + public function fire(string $subject, string $message, array $raw): bool + { + if (!$this->isValid()) + return false; + $addrsOut = []; + if (preg_match_all('/[^@\s]+@[^@\s]+/', $this->extraMails, $out)) { + $addrsOut = $out[0]; + } + if (!empty($this->userIds)) { + $mails = Database::queryColumnArray("SELECT email + FROM user + WHERE userid IN (:users)", + ['users' => $this->userIds]); + foreach ($mails as $mail) { + if (preg_match('/^[^@\s]+@[^@\s]+$/', $mail)) { + $addrsOut[] = $mail; + } + } + } + if (empty($addrsOut)) + return false; + Mailer::queue($this->mailConfigId, $addrsOut, $subject, $message); + return true; + } + + public function isValid(): bool + { + if ($this->mailConfigId === 0) + return false; + $mailer = Mailer::instanceFromConfig($this->mailConfigId); + return $mailer !== null; + } +} + +class IrcNotificationTransport extends NotificationTransport +{ + + private $server; + + private $serverPasswort; + + private $target; + + private $nickName; + + public function __construct(array $data) + { + $this->server = $data['irc-server'] ?? ''; + $this->serverPasswort = $data['irc-server-password'] ?? ''; + $this->target = $data['irc-target'] ?? ''; + $this->nickName = $data['irc-nickname'] ?? 'BWLP-' . mt_rand(10000, 99999); + } + + public function toString(): string + { + return '(' . $this->server . '), ' . $this->nickName . ' @ ' . $this->target; + } + + public function fire(string $subject, string $message, array $raw): bool + { + if (!$this->isValid()) + return false; + return !Taskmanager::isFailed(Taskmanager::submit('IrcNotification', [ + 'serverAddress' => $this->server, + 'serverPassword' => $this->serverPasswort, + 'channel' => $this->target, + 'message' => preg_replace('/[\r\n]+\s*/', ' ', $message), + 'nickName' => $this->nickName, + ])); + } + + public function isValid(): bool + { + return !empty($this->server) && !empty($this->target); + } +} + +class HttpNotificationTransport extends NotificationTransport +{ + + /** @var string */ + private $uri; + + /** @var string */ + private $method; + + /** @var string */ + private $postField; + + /** @var string */ + private $postFormat; + + public function __construct(array $data) + { + $this->uri = $data['http-uri'] ?? ''; + $this->method = $data['http-method'] ?? 'POST'; + $this->postField = $data['http-post-field'] ?? 'message=%TEXT%&subject=%SUBJECT%'; + $this->postFormat = $data['http-post-format'] ?? 'FORM'; + } + + public function toString(): string + { + return $this->uri . ' (' . $this->method . ')'; + } + + public function fire(string $subject, string $message, array $raw): bool + { + if (!$this->isValid()) + return false; + $url = str_replace(['%TEXT%', '%SUBJECT%'], [urlencode($message), urlencode($subject)], $this->uri); + if ($this->method === 'POST') { + switch ($this->postFormat) { + case 'FORM': + $body = str_replace(['%TEXT%', '%SUBJECT%'], [urlencode($message), urlencode($subject)], $this->postField); + $ctype = 'application/x-www-form-urlencoded'; + break; + case 'JSON': + $body = str_replace(['%TEXT%', '%SUBJECT%'], [json_encode($message), + json_encode($subject)], $this->postField); + $ctype = 'application/json'; + break; + default: + $out = []; + foreach ($raw as $k1 => $a) { + foreach ($a as $k2 => $v) { + $out["$k1.$k2"] = $v; + } + } + $body = json_encode($out); + $ctype = 'application/json'; + } + } else { + $body = null; + $ctype = null; + } + return !Taskmanager::isFailed(Taskmanager::submit('HttpRequest', [ + 'url' => $url, + 'postData' => $body, + 'contentType' => $ctype, + ])); + } + + public function isValid(): bool + { + return !empty($this->uri); + } +} + +class GroupNotificationTransport extends NotificationTransport +{ + + /** @var int[] list of contained notification transports */ + private $list; + + public function __construct(array $data) + { + $this->list = array_map(function ($i) { return (int)$i; }, $data['group-list'] ?? []); + } + + public function toString(): string + { + static $groupList = null; + if ($groupList === null) { + $groupList = Database::queryKeyValueList("SELECT transportid, title FROM notification_backend"); + } + $out = array_map(function ($i) use ($groupList) { return $groupList[$i] ?? "#$i"; }, $this->list); + return implode(', ', $out); + } + + public function fire(string $subject, string $message, array $raw): bool + { + // This is static, so recursing into groups will keep track of ones we already saw + static $done = false; + $first = ($done === false); + if ($first) { // Non-recursive call, init list + $done = []; + } + $list = array_diff($this->list, $done); + if (!empty($list)) { + $done = array_merge($done, $list); + $res = Database::simpleQuery("SELECT data FROM notification_backend WHERE transportid IN (:ids)", + ['ids' => $list]); + foreach ($res as $row) { + $data = json_decode($row['data'], true); + if (is_array($data)) { + $inst = NotificationTransport::getInstance($data); + if ($inst !== null) { + $inst->fire($subject, $message, $raw); + } + } + } + } + if ($first) { + $done = false; // Outer-most call, reset + } + return true; + } + + public function isValid(): bool + { + // Do we really care about empty groups? They might be pointless, but not really invalid + // We could consider groups containing invalid IDs as invalid, but that would mean that we + // potentially ignore all the other existing IDs in this group, as it would never fire + return true; + } +}
\ No newline at end of file diff --git a/modules-available/eventlog/install.inc.php b/modules-available/eventlog/install.inc.php index e5fd32f6..3d252138 100644 --- a/modules-available/eventlog/install.inc.php +++ b/modules-available/eventlog/install.inc.php @@ -13,6 +13,41 @@ KEY `dateline` (`dateline`), KEY `logtypeid` (`logtypeid`,`dateline`) "); +$res[] = tableCreate('notification_rule', ' + `ruleid` int(10) UNSIGNED NOT NULL AUTO_INCREMENT, + `title` varchar(100) NOT NULL, + `description` text, + `type` varchar(40) NOT NULL CHARACTER SET ascii, + `datafilter` blob NOT NULL, + `subject` varchar(200) NOT NULL, + `message` text NOT NULL, + PRIMARY KEY (`ruleid`), + KEY `type` (`type`) +'); + +$res[] = tableCreate('notification_backend', ' + `transportid` int(10) UNSIGNED NOT NULL AUTO_INCREMENT, + `title` varchar(100) NOT NULL, + `description` text, + `data` blob, + PRIMARY KEY (`transportid`), + KEY (`title`) +'); + +$res[] = tableCreate('notification_rule_x_transport', ' + `ruleid` int(10) UNSIGNED NOT NULL, + `transportid` int(10) UNSIGNED NOT NULL, + PRIMARY KEY (`ruleid`, `transportid`), + KEY (`transportid`) +'); + +$res[] = tableCreate('notification_sample', ' + `type` varchar(40) CHARACTER SET ascii NOT NULL, + `dateline` int(10) UNSIGNED NOT NULL, + `data` blob, + KEY (`type`, `dateline`) +'); + // Update path if (!tableHasColumn('eventlog', 'extra')) { @@ -22,6 +57,12 @@ if (!tableHasColumn('eventlog', 'extra')) { $res[] = UPDATE_DONE; } +// 2021-06-15: Add constraints to filter/backend stuff +$res[] = tableAddConstraint('notification_rule_x_transport', 'ruleid', + 'notification_rule', 'ruleid', 'ON UPDATE CASCADE ON DELETE CASCADE'); +$res[] = tableAddConstraint('notification_rule_x_transport', 'transportid', + 'notification_backend', 'transportid', 'ON UPDATE CASCADE ON DELETE CASCADE'); + // Create response for browser if (in_array(UPDATE_DONE, $res)) { diff --git a/modules-available/eventlog/page.inc.php b/modules-available/eventlog/page.inc.php index 250e1b24..9006c3c5 100644 --- a/modules-available/eventlog/page.inc.php +++ b/modules-available/eventlog/page.inc.php @@ -3,56 +3,43 @@ class Page_EventLog extends Page { + private $show; + protected function doPreprocess() { User::load(); - User::assertPermission('view'); - User::setLastSeenEvent(Property::getLastWarningId()); - } - protected function doRender() - { - Render::addTemplate("heading"); - $lines = array(); - $paginate = new Paginate("SELECT logid, dateline, logtypeid, description, extra FROM eventlog ORDER BY logid DESC", 50); - $res = $paginate->exec(); - foreach ($res as $row) { - $row['date'] = Util::prettyTime($row['dateline']); - $row['icon'] = $this->typeToIcon($row['logtypeid']); - $row['color'] = $this->typeToColor($row['logtypeid']); - $lines[] = $row; + $this->show = Request::any('show', false, 'string'); + if ($this->show === false && Request::isGet()) { + if (User::hasPermission('view')) { + $this->show = 'log'; + } elseif (User::hasPermission('filter.rules.view')) { + $this->show = 'rules'; + } else { + User::assertPermission('filter.transports.view'); + $this->show = 'transports'; + } } - - $paginate->render('_page', array( - 'list' => $lines - )); - } - - private function typeToIcon($type) - { - switch ($type) { - case 'info': - return 'ok'; - case 'warning': - return 'exclamation-sign'; - case 'failure': - return 'remove'; - default: - return 'question-sign'; + if ($this->show !== false) { + $this->show = preg_replace('/[^a-z0-9_\-]/', '', $this->show); + if (!file_exists('modules/eventlog/pages/' . $this->show . '.inc.php')) { + Message::addError('main.invalid-action', $this->show); + Util::redirect('?do=eventlog'); + } else { + require_once 'modules/eventlog/pages/' . $this->show . '.inc.php'; + SubPage::doPreprocess(); + } + } + if (Request::isPost()) { + Util::redirect('?do=eventlog&show=' . $this->show); } } - private function typeToColor($type) + protected function doRender() { - switch ($type) { - case 'info': - return ''; - case 'warning': - return 'orange'; - case 'failure': - return 'red'; - default: - return ''; + Render::addTemplate('page-header', ['active_' . $this->show => 'active']); + if ($this->show !== false) { + SubPage::doRender(); } } diff --git a/modules-available/eventlog/pages/log.inc.php b/modules-available/eventlog/pages/log.inc.php new file mode 100644 index 00000000..a48b4a95 --- /dev/null +++ b/modules-available/eventlog/pages/log.inc.php @@ -0,0 +1,57 @@ +<?php + +class SubPage +{ + + public static function doPreprocess() + { + User::assertPermission('view'); + User::setLastSeenEvent(Property::getLastWarningId()); + } + + public static function doRender() + { + $lines = array(); + $paginate = new Paginate("SELECT logid, dateline, logtypeid, description, extra FROM eventlog ORDER BY logid DESC", 50); + $res = $paginate->exec(); + foreach ($res as $row) { + $row['date'] = Util::prettyTime($row['dateline']); + $row['icon'] = self::typeToIcon($row['logtypeid']); + $row['color'] = self::typeToColor($row['logtypeid']); + $lines[] = $row; + } + + $paginate->render('_page', array( + 'list' => $lines + )); + } + + private static function typeToIcon($type) + { + switch ($type) { + case 'info': + return 'ok'; + case 'warning': + return 'exclamation-sign'; + case 'failure': + return 'remove'; + default: + return 'question-sign'; + } + } + + private static function typeToColor($type) + { + switch ($type) { + case 'info': + return ''; + case 'warning': + return 'orange'; + case 'failure': + return 'red'; + default: + return ''; + } + } + +}
\ No newline at end of file diff --git a/modules-available/eventlog/pages/mailconfigs.inc.php b/modules-available/eventlog/pages/mailconfigs.inc.php new file mode 100644 index 00000000..6d5d20b6 --- /dev/null +++ b/modules-available/eventlog/pages/mailconfigs.inc.php @@ -0,0 +1,98 @@ +<?php + +class SubPage +{ + + const OP_LIST = ['=', '!=', '<', '<=', '>', '>=', 'regex']; + + public static function doPreprocess() + { + if (Request::isPost()) { + $action = Request::post('action'); + if ($action === 'save-mailconfig') { + self::saveMailconfig(); + } elseif ($action === 'delete-mailconfig') { + self::deleteMailconfig(); + } else { + Message::addError('main.invalid-action', $action); + } + Util::redirect('?do=eventlog&show=mailconfigs'); + } + } + + private static function saveMailconfig() + { + User::assertPermission('filter.mailconfigs.edit'); + $id = Request::post('id', Request::REQUIRED, 'int'); + $data = [ + 'host' => Request::post('host', Request::REQUIRED, 'string'), + 'port' => Request::post('port', Request::REQUIRED, 'int'), + 'ssl' => Request::post('ssl', Request::REQUIRED, 'string'), + 'senderaddress' => Request::post('senderaddress', Request::REQUIRED, 'string'), + 'replyto' => Request::post('replyto', '', 'string'), + 'username' => Request::post('username', '', 'string'), + 'password' => Request::post('password', '', 'string'), + ]; + if ($id === 0) { + // NEW + Database::exec("INSERT INTO mail_config (host, port, `ssl`, senderaddress, replyto, username, password) + VALUES (:host, :port, :ssl, :senderaddress, :replyto, :username, :password)", $data); + } else { + // UPDATE + $data['configid'] = $id; + Database::exec("UPDATE mail_config SET host = :host, port = :port, `ssl` = :ssl, + senderaddress = :senderaddress, replyto = :replyto, username = :username, password = :password + WHERE configid = :configid", $data); + } + Message::addSuccess("event-mailconfig-saved", $id); + Util::redirect('?do=eventlog&show=mailconfigs'); + } + + private static function deleteMailconfig() + { + User::assertPermission('filter.mailconfigs.edit'); + $id = Request::post('id', Request::REQUIRED, 'int'); + Database::exec("DELETE FROM mail_config WHERE configid = :id", ['id' => $id]); + } + + /* + * + */ + + public static function doRender() + { + $id = Request::get('id', null, 'int'); + if ($id !== null) { + self::showMailconfigEditor($id); + } else { + // LIST + $data = []; + $data['configs'] = Database::queryAll('SELECT configid, host, port, `ssl`, senderaddress, replyto + FROM mail_config + ORDER BY host'); + Render::addTemplate('page-filters-mailconfigs', $data); + } + } + + /** + * @param int $id Config to edit. If id is 0, a new config will be created. + */ + private static function showMailconfigEditor(int $id) + { + if ($id !== 0) { + // EDIT + $data = Database::queryFirst('SELECT configid, host, port, `ssl`, senderaddress, replyto, + username, password + FROM mail_config + WHERE configid = :id', ['id' => $id]); + if ($data === false) { + Message::addError('invalid-mailconfig-id', $id); + Util::redirect('?do=eventlog&show=mailconfigs'); + } + } else { + $data = ['configid' => 0]; + } + Render::addTemplate('page-filters-edit-mailconfig', $data); + } + +}
\ No newline at end of file diff --git a/modules-available/eventlog/pages/rules.inc.php b/modules-available/eventlog/pages/rules.inc.php new file mode 100644 index 00000000..131c4eb6 --- /dev/null +++ b/modules-available/eventlog/pages/rules.inc.php @@ -0,0 +1,172 @@ +<?php + +class SubPage +{ + + const OP_LIST = ['*', '=', '!=', '<', '<=', '>', '>=', 'regex']; + + public static function doPreprocess() + { + if (Request::isPost()) { + $action = Request::post('action'); + if ($action === 'save-filter') { + self::saveRule(); + } elseif ($action === 'delete-filter') { + self::deleteRule(); + } else { + Message::addError('main.invalid-action', $action); + } + Util::redirect('?do=eventlog&show=rules'); + } + } + + private static function saveRule() + { + User::assertPermission('filter.rules.edit'); + $id = Request::post('id', Request::REQUIRED, 'int'); + $type = Request::post('type', Request::REQUIRED, 'string'); + $title = Request::post('title', Request::REQUIRED, 'string'); + $message = Request::post('message', Request::REQUIRED, 'string'); + $transports = Request::post('transports', [], 'array'); + $filters = Request::post('filter', Request::REQUIRED, 'array'); + $filters = array_filter($filters, function ($item) { + return is_array($item) && !empty($item['path']) && !empty($item['op']); + }); + foreach ($filters as $index => &$item) { + $item['index'] = $index; + } + unset($item); + if (empty($filters)) { + Message::addError('no-valid-filters'); + Util::redirect('?do=eventlog&show=rules'); + } + if ($id === 0) { + $id = null; + } + $data = [ + 'id' => $id, + 'type' => $type, + 'title' => $title, + 'data' => json_encode(['list' => array_values($filters)]), + 'subject' => Request::post('subject', '', 'string'), + 'message' => $message, + ]; + if ($id === null) { + // NEW + Database::exec("INSERT INTO notification_rule (ruleid, type, title, datafilter, subject, message) + VALUES (:id, :type, :title, :data, :subject, :message)", $data); + $id = Database::lastInsertId(); + } else { + Database::exec("UPDATE notification_rule SET type = :type, title = :title, datafilter = :data, + subject = :subject, message = :message + WHERE ruleid = :id", $data); + } + if (empty($transports)) { + Database::exec("DELETE FROM notification_rule_x_transport WHERE ruleid = :id", ['id' => $id]); + } else { + Database::exec("DELETE FROM notification_rule_x_transport + WHERE ruleid = :id AND transportid NOT IN (:transports)", + ['id' => $id, 'transports' => $transports]); + Database::exec("INSERT IGNORE INTO notification_rule_x_transport (ruleid, transportid) + VALUES :list", ['list' => array_map(function ($i) use ($id) { return [$id, $i]; }, $transports)]); + } + Message::addSuccess("event-rule-saved", $id); + Util::redirect('?do=eventlog&show=rules'); + } + + private static function deleteRule() + { + User::assertPermission('filter.rules.edit'); + $id = Request::post('id', Request::REQUIRED, 'int'); + Database::exec("DELETE FROM notification_rule WHERE ruleid = :id", ['id' => $id]); + } + + /* + * + */ + + public static function doRender() + { + $id = Request::get('id', null, 'int'); + if ($id !== null) { + self::showRuleEditor($id); + } else { + // LIST + $data = []; + $data['filters'] = Database::queryAll('SELECT ruleid, type, title, datafilter, + Count(transportid) AS useCount + FROM notification_rule + LEFT JOIN notification_rule_x_transport sfxb USING (ruleid) + GROUP BY ruleid, title + ORDER BY title, ruleid'); + Render::addTemplate('page-filters-rules', $data); + } + } + + /** + * @param int $id Rule to edit. If id is 0, a new rule will be created. + */ + private static function showRuleEditor(int $id) + { + // EDIT + $index = 0; + $existing = []; + if ($id !== 0) { + $data = Database::queryFirst('SELECT ruleid, title, type, datafilter, subject, message + FROM notification_rule WHERE ruleid = :id', ['id' => $id]); + if ($data === false) { + Message::addError('invalid-rule-id', $id); + Util::redirect('?do=eventlog&show=rules'); + } + $list = json_decode($data['datafilter'], true); + if (!is_array($list['list'])) { + $list['list'] = []; + } + foreach ($list['list'] as $item) { + if (isset($item['index'])) { + $existing[] = $item['index']; + } + } + foreach ($list['list'] as &$item) { + if (!isset($item['index'])) { + while (in_array($index, $existing)) { + $index++; + } + $item['index'] = $index++; + } + $item['operators'] = []; + foreach (self::OP_LIST as $op) { + $item['operators'][] = [ + 'name' => $op, + 'selected' => ($op === $item['op']) ? 'selected' : '', + ]; + } + } + $data['filter'] = $list['list']; + } else { + $data = ['filter' => [], 'ruleid' => 0]; + } + for ($i = 0; $i < 2; ++$i) { + while (in_array($index, $existing)) { + $index++; + } + $data['filter'][] = [ + 'index' => $index++, + 'operators' => array_map(function ($item) { return ['name' => $item]; }, self::OP_LIST), + ]; + } + // Add suggestions for type + $data['types'] = Database::queryColumnArray("SELECT DISTINCT type + FROM notification_sample + ORDER BY type"); + // + Module::isAvailable('bootstrap_multiselect'); + $data['transports'] = Database::queryAll("SELECT nb.transportid, nb.title, + IF(sfxb.ruleid IS NULL, '', 'selected') AS selected + FROM notification_backend nb + LEFT JOIN notification_rule_x_transport sfxb ON (sfxb.transportid = nb.transportid AND sfxb.ruleid = :id)", + ['id' => $id]); + Render::addTemplate('page-filters-edit-rule', $data); + } + +}
\ No newline at end of file diff --git a/modules-available/eventlog/pages/transports.inc.php b/modules-available/eventlog/pages/transports.inc.php new file mode 100644 index 00000000..b72f36f9 --- /dev/null +++ b/modules-available/eventlog/pages/transports.inc.php @@ -0,0 +1,176 @@ +<?php + +class SubPage +{ + + public static function doPreprocess() + { + if (Request::isPost()) { + $action = Request::post('action'); + if ($action === 'save-transport') { + self::saveTransport(); + } elseif ($action === 'delete-transport') { + self::deleteTransport(); + } else { + Message::addError('main.invalid-action', $action); + } + Util::redirect('?do=eventlog&show=transports'); + } + } + + private static function saveTransport() + { + User::assertPermission('filter.transports.edit'); + $id = Request::post('id', Request::REQUIRED, 'int'); + $rules = Request::post('rules', [], 'array'); + static $types = [ + 'type' => [Request::REQUIRED, 'string', ['mail', 'irc', 'http', 'group']], + 'mail-config-id' => [0, 'int'], + 'mail-users' => [[], 'int[]'], + 'mail-extra-mails' => ['', 'string'], + 'irc-server' => ['', 'string'], + 'irc-server-password' => ['', 'string'], + 'irc-target' => ['', 'string'], + 'irc-nickname' => ['', 'string'], + 'http-uri' => ['', 'string'], + 'http-method' => ['', 'string', ['GET', 'POST']], + 'http-post-field' => ['', 'string'], + 'http-post-format' => ['', 'string', ['FORM', 'JSON', 'JSON_AUTO']], + 'group-list' => [[], 'int[]'], + ]; + $data = []; + foreach ($types as $key => $def) { + if (substr($def[1], -1) === ']') { + $type = substr($def[1], 0, -2); + $array = true; + } else { + $type = $def[1]; + $array = false; + } + if ($array) { + $value = Request::post($key, [], 'array'); + foreach ($value as &$v) { + settype($v, $type); + if (isset($def[2]) && !in_array($v, $def[2])) { + Message::addWarning('main.value-invalid', $key, $v); + } + } + } else { + $value = Request::post($key, $def[0], $type); + if (isset($def[2]) && !in_array($value, $def[2])) { + Message::addWarning('main.value-invalid', $key, $value); + } + } + $data[$key] = $value; + } + //die(print_r($data)); + $params = [ + 'title' => Request::post('title', 'Backend', 'string'), + 'description' => Request::post('description', '', 'string'), + 'data' => json_encode($data), + ]; + if ($id === 0) { + $res = Database::exec("INSERT INTO notification_backend (title, description, data) + VALUES (:title, :description, :data)", $params); + $id = Database::lastInsertId(); + } else { + $params['transportid'] = $id; + $res = Database::exec("UPDATE notification_backend + SET title = :title, description = :description, data = :data + WHERE transportid = :transportid", $params); + } + if (empty($rules)) { + Database::exec("DELETE FROM notification_rule_x_transport WHERE transportid = :id", ['id' => $id]); + } else { + Database::exec("DELETE FROM notification_rule_x_transport + WHERE transportid = :id AND ruleid NOT IN (:rules)", + ['id' => $id, 'rules' => $rules]); + Database::exec("INSERT IGNORE INTO notification_rule_x_transport (ruleid, transportid) + VALUES :list", ['list' => array_map(function ($i) use ($id) { return [$i, $id]; }, $rules)]); + } + if ($res > 0) { + Message::addSuccess('transport-saved', $id); + } + Util::redirect('?do=eventlog&show=transports§ion=transports'); + } + + private static function deleteTransport() + { + User::assertPermission('filter.transports.edit'); + $id = Request::post('id', Request::REQUIRED, 'int'); + Database::exec("DELETE FROM notification_backend WHERE transportid = :id", ['id' => $id]); + } + + /* + * + */ + + public static function doRender() + { + $id = Request::get('id', null, 'int'); + if ($id !== null) { + self::showTransportEditor($id); + } else { + // LIST + $data = []; + $data['transports'] = []; + foreach (Database::queryAll('SELECT transportid, title, data, + Count(ruleid) AS useCount + FROM notification_backend nb + LEFT JOIN notification_rule_x_transport sfxb USING (transportid) + GROUP BY transportid, title + ORDER BY title, transportid') as $transport) { + $json = json_decode($transport['data'], true); + $transport['type'] = $json['type']; + $transport['details'] = NotificationTransport::getInstance($json); + $data['transports'][] = $transport; + } + Render::addTemplate('page-filters-transports', $data); + } + } + + /** + * @param int $id Transport to edit, 0 to create a new one + */ + private static function showTransportEditor(int $id) + { + if ($id !== 0) { + $entry = Database::queryFirst('SELECT transportid, title, description, data + FROM notification_backend + WHERE transportid = :id', ['id' => $id]); + if ($entry === false) { + Message::addError('invalid-transport-id', $id); + Util::redirect('?do=eventlog&show=transports§ion=transports'); + } + $entry['data'] = json_decode($entry['data'], true); + $entry[($entry['data']['type'] ?? '') . '_selected'] = 'selected'; + $entry[($entry['data']['http-method'] ?? '') . '_selected'] = 'selected'; + $entry[($entry['data']['http-post-format'] ?? '') . '_selected'] = 'selected'; + } else { + $entry = ['transportid' => $id]; + } + $entry['users'] = []; + foreach (Database::queryAll("SELECT userid, login, fullname, email FROM user ORDER BY login") as $row) { + $row['disabled'] = strpos($row['email'], '@') ? '' : 'disabled'; + $row['selected'] = in_array($row['userid'], $entry['data']['mail-users'] ?? []) ? 'selected' : ''; + $entry['users'][] = $row; + } + $entry['mailconfigs'] = []; + foreach (Database::queryAll("SELECT configid, host, port, senderaddress FROM mail_config") as $row) { + $row['selected'] = $row['configid'] == $entry['data']['mail-config-id'] ? 'selected' : ''; + $entry['mailconfigs'][] = $row; + } + foreach (Database::queryAll("SELECT transportid, title FROM notification_backend") as $row) { + $row['selected'] = in_array($row['transportid'], ($entry['data']['group-list'] ?? [])) ? 'selected' : ''; + $entry['backends'][] = $row; + } + Module::isAvailable('bootstrap_multiselect'); + $entry['rules'] = Database::queryAll("SELECT sf.ruleid, sf.title, + IF(sfxb.transportid IS NULL, '', 'selected') AS selected + FROM notification_rule sf + LEFT JOIN notification_rule_x_transport sfxb ON (sf.ruleid = sfxb.ruleid AND sfxb.transportid = :id)", + ['id' => $id]); + Render::addTemplate('page-filters-edit-transport', $entry); + } + +}
\ No newline at end of file diff --git a/modules-available/eventlog/permissions/permissions.json b/modules-available/eventlog/permissions/permissions.json index a1748957..e155458c 100644 --- a/modules-available/eventlog/permissions/permissions.json +++ b/modules-available/eventlog/permissions/permissions.json @@ -1,5 +1,17 @@ { "view": { "location-aware": false + }, + "filter.rules.view": { + "location-aware": false + }, + "filter.rules.edit": { + "location-aware": false + }, + "filter.transports.view": { + "location-aware": false + }, + "filter.transports.edit": { + "location-aware": false } }
\ No newline at end of file diff --git a/modules-available/eventlog/templates/_page.html b/modules-available/eventlog/templates/_page.html index 6be0bbb8..facdd205 100644 --- a/modules-available/eventlog/templates/_page.html +++ b/modules-available/eventlog/templates/_page.html @@ -1,3 +1,4 @@ +<h2>{{lang_eventLog}}</h2> {{{pagenav}}} <table class="table table-striped table-condensed"> <thead> diff --git a/modules-available/eventlog/templates/heading.html b/modules-available/eventlog/templates/heading.html index 37612a77..e69de29b 100644 --- a/modules-available/eventlog/templates/heading.html +++ b/modules-available/eventlog/templates/heading.html @@ -1 +0,0 @@ -<h1>{{lang_eventLog}}</h1>
\ No newline at end of file diff --git a/modules-available/eventlog/templates/page-filters-edit-mailconfig.html b/modules-available/eventlog/templates/page-filters-edit-mailconfig.html new file mode 100644 index 00000000..07d6e9c0 --- /dev/null +++ b/modules-available/eventlog/templates/page-filters-edit-mailconfig.html @@ -0,0 +1,54 @@ +<h2>{{title}}</h2> + + +<form method="post" action="?do=eventlog&show=mailconfigs"> + <input type="hidden" name="token" value="{{token}}"> + <input type="hidden" name="id" value="{{configid}}"> + <div class="form-group row"> + <div class="col-md-4"> + <label for="i-host">{{lang_host}}</label> + <input id="i-host" class="form-control" name="host" value="{{host}}" required> + </div> + <div class="col-md-4"> + <label for="i-ssl">{{lang_ssl}}</label> + <select name="ssl" id="i-ssl" class="form-control"> + <option value="IMPLICIT" {{IMPLICIT_selected}}>{{lang_sslImplicit}}</option> + <option value="NONE" {{NONE_selected}}>{{lang_sslNone}}</option> + <option value="EXPLICIT" {{EXPLICIT_selected}}>{{lang_sslExplicit}}</option> + </select> + </div> + <div class="col-md-4"> + <label for="i-port">{{lang_port}}</label> + <input id="i-port" type="number" min="1" max="65535" class="form-control" name="port" value="{{port}}" required> + </div> + </div> + <div class="form-group row"> + <div class="col-sm-6"> + <label for="i-username">{{lang_username}}</label> + <input id="i-username" class="form-control" name="username" value="{{username}}"> + </div> + <div class="col-sm-6"> + <label for="i-password">{{lang_password}}</label> + <input id="i-password" type="{{password_type}}" class="form-control" name="password" value="{{password}}"> + </div> + </div> + <div class="form-group row"> + <div class="col-sm-6"> + <label for="i-senderaddress">{{lang_senderAddress}}</label> + <input id="i-senderaddress" class="form-control" name="senderaddress" value="{{senderaddress}}" required> + </div> + <div class="col-sm-6"> + <label for="i-replyto">{{lang_replyTo}}</label> + <input id="i-replyto" class="form-control" name="replyto" value="{{replyto}}"> + </div> + </div> + <div class="buttonbar text-right"> + <a class="btn btn-default" href="?do=eventlog&show=mailconfigs"> + {{lang_cancel}} + </a> + <button class="btn btn-primary" type="submit" name="action" value="save-mailconfig"> + <span class="glyphicon glyphicon-floppy-disk"></span> + {{lang_save}} + </button> + </div> +</form>
\ No newline at end of file diff --git a/modules-available/eventlog/templates/page-filters-edit-rule.html b/modules-available/eventlog/templates/page-filters-edit-rule.html new file mode 100644 index 00000000..72a53e9a --- /dev/null +++ b/modules-available/eventlog/templates/page-filters-edit-rule.html @@ -0,0 +1,102 @@ +<h2>{{title}}</h2> + + +<form method="post" action="?do=eventlog&show=rules"> + <input type="hidden" name="token" value="{{token}}"> + <input type="hidden" name="id" value="{{ruleid}}"> + <div class="form-group row"> + <div class="col-md-4"> + <label for="i-type">{{lang_type}}</label> + <input autocomplete="off" id="i-type" list="i-types" class="form-control" name="type" value="{{type}}" required> + <datalist id="i-types"> + {{#types}} + <option value="{{.}}">{{lang_typeExample}}: {{.}}</option> + {{/types}} + </datalist> + </div> + <div class="col-md-8"> + <label for="i-title">{{lang_title}}</label> + <input id="i-title" class="form-control" name="title" value="{{title}}" required> + </div> + </div> + <div class="form-group row"> + <div class="col-md-12"> + <label for="i-transports">{{lang_transports}}</label> + <select multiple name="transports[]" id="i-transports" class="form-control multilist"> + {{#transports}} + <option value="{{transportid}}" {{selected}}>{{title}}</option> + {{/transports}} + </select> + </div> + </div> + {{#filter}} + <div class="form-group"> + <label> + <span class="form-control">{{index}}</span> + </label> + <label> + {{lang_filterPath}} + <input class="form-control" name="filter[{{index}}][path]" value="{{path}}"> + </label> + <label> + {{lang_filterOp}} + <select class="form-control op-select" name="filter[{{index}}][op]" data-index="{{index}}"> + {{#operators}} + <option {{selected}}>{{name}}</option> + {{/operators}} + </select> + </label> + <label> + {{lang_filterArg}} + <input class="form-control op-arg" name="filter[{{index}}][arg]" value="{{arg}}" data-index="{{index}}"> + </label> + <!--label> + {{lang_filterResult}} + <input class="form-control" name="filter[{{index}}][result]" value="{{result}}"> + </label--> + </div> + {{/filter}} + <div> + <p>{{lang_hintRegex}}</p> + </div> + <div class="form-group"> + <label for="i-subject">{{lang_subject}}</label> + <input id="i-subject" class="form-control" name="subject" value="{{subject}}"> + </div> + <div class="form-group"> + <label for="msg-txt"> + {{lang_messageTemplate}} + </label> + <textarea required id="msg-txt" name="message" class="form-control" rows="10" cols="80">{{message}}</textarea> + <p> + {{lang_messageTemplateHelp}} Platzhalter %zeile.n%, blabla.... + </p> + </div> + <div class="buttonbar text-right"> + <a class="btn btn-default" href="?do=eventlog&show=rules"> + {{lang_cancel}} + </a> + <button class="btn btn-primary" type="submit" name="action" value="save-filter"> + <span class="glyphicon glyphicon-floppy-disk"></span> + {{lang_save}} + </button> + </div> +</form> + +<script> + document.addEventListener('DOMContentLoaded', function () { + var $multilists = $("select.multilist"); + if ($multilists.multiselect) { + $multilists.multiselect({ + includeSelectAllOption: true, + buttonWidth: '100%', + buttonClass: 'form-control' + }); + } + $('.op-select').change(function() { + var $t = $(this); + var disabled = $t.val() === '*'; + $('.op-arg[data-index=' + $t.data('index') + ']').prop('disabled', disabled); + }).change(); + }); +</script>
\ No newline at end of file diff --git a/modules-available/eventlog/templates/page-filters-edit-transport.html b/modules-available/eventlog/templates/page-filters-edit-transport.html new file mode 100644 index 00000000..77213b28 --- /dev/null +++ b/modules-available/eventlog/templates/page-filters-edit-transport.html @@ -0,0 +1,190 @@ +<h2>{{lang_editFilter}} {{#title}}–{{/title}} {{title}}</h2> + +<form method="post" action="?do=eventlog&show=transports"> + <input type="hidden" name="token" value="{{token}}"> + <input type="hidden" name="id" value="{{transportid}}"> + + <div class="form-group row"> + <div class="col-sm-6"> + <label for="title">{{lang_title}}</label> + <input id="title" name="title" class="form-control" value="{{title}}" required> + </div> + <div class="col-sm-6"> + <label for="transport-select"> + {{lang_type}} + </label> + <select id="transport-select" class="form-control" name="type"> + <option value="mail" {{mail_selected}}>{{lang_mail}}</option> + <option value="irc" {{irc_selected}}>{{lang_irc}}</option> + <option value="http" {{http_selected}}>{{lang_http}}</option> + <option value="group" {{group_selected}}>{{lang_transportGroup}}</option> + </select> + </div> + </div> + <hr> + + <div class="transport-list"> + <div id="transport-mail"> + <div class="form-group row"> + <div class="col-md-6"> + <label for="mail-config">{{lang_mailConfig}}</label> + <select class="form-control" name="mail-config-id" id="mail-config"> + {{^mailconfigs}} + <option value="0" disabled>{{lang_noMailConfig}}</option> + {{/mailconfigs}} + {{#mailconfigs}} + <option value="{{configid}}" {{selected}}>{{senderaddress}} @ {{host}}:{{port}}</option> + {{/mailconfigs}} + </select> + </div> + <div class="col-md-6"> + <label for="mail-users">{{lang_mailUsers}}</label> + <select class="form-control multilist" name="mail-users[]" multiple id="mail-users"> + {{#users}} + <option value="{{userid}}" {{selected}} {{disabled}}>{{login}} - {{fullname}} - {{email}}</option> + {{/users}} + </select> + </div> + </div> + <div class="form-group row"> + <div class="col-md-12"> + <label for="mail-extra-mails">{{lang_additionalMailAddresses}}</label> + <textarea class="form-control" name="mail-extra-mails" id="mail-extra-mails">{{data.mail-extra-mails}}</textarea> + </div> + </div> + </div> + + <div id="transport-irc"> + <div class="form-group row"> + <div class="col-md-4 col-sm-6"> + <label for="irc-server">{{lang_ircServer}}</label> + <input id="irc-server" name="irc-server" class="form-control" value="{{data.irc-server}}" + placeholder="irc.example.com"> + </div> + <div class="col-md-4 col-sm-6"> + <label for="irc-target">{{lang_ircTarget}}</label> + <input id="irc-target" name="irc-target" class="form-control" value="{{data.irc-target}}" placeholder="#foo"> + </div> + <div class="col-md-4 col-sm-6"> + <label for="irc-server-passwd">{{lang_ircServerPassword}}</label> + <input id="irc-server-passwd" name="irc-server-passwd" class="form-control" + value="{{data.irc-server-passwd}}"> + </div> + </div> + <div class="form-group row"> + <div class="col-md-4 col-sm-6"> + <label for="irc-nickname">{{lang_ircNickname}}</label> + <input id="irc-nickname" name="irc-nickname" class="form-control" value="{{data.irc-nickname}}" + placeholder="Brunhilde"> + </div> + </div> + </div> + + <div id="transport-http"> + <div class="form-group row"> + <div class="col-md-10"> + <label for="http-uri">{{lang_httpUri}}</label> + <input id="http-uri" name="http-uri" class="form-control" value="{{data.http-uri}}" + placeholder="https://example.com/bwlp"> + <p>{{lang_uriUseSUBJECTandTEXThint}}</p> + </div> + <div class="col-md-2"> + <label for="http-method">{{lang_httpMethod}}</label> + <select id="http-method" name="http-method" class="form-control"> + <option {{POST_selected}}>POST</option> + <option {{GET_selected}}>GET</option> + </select> + </div> + </div> + <div id="post-options" class="form-group row"> + <div class="col-md-7"> + <label for="http-post-field">{{lang_httpPostField}}</label> + <input id="http-post-field" name="http-post-field" class="form-control" value="{{data.http-post-field}}" + placeholder="key=1234&message=%TEXT%"> + <p>{{lang_postUseSUBJECTandTEXThint}}</p> + </div> + <div class="col-md-5"> + <label for="http-post-format">{{lang_httpPostFormat}}</label> + <select id="http-post-format" name="http-post-format" class="form-control"> + <option value="FORM" {{FORM_selected}} aria-describedby="d-fd">FORM-data (urlencode)</option> + <option value="JSON" {{JSON_selected}} aria-describedby="d-js">json string</option> + <option value="JSON_AUTO" {{JSON_AUTO_selected}} aria-describedby="d-aj">{{lang_autoJson}}</option> + </select> + <div id="d-fd"><b>FORM-data</b>: {{lang_formDataHelp}}</div> + <div id="d-js"><b>json string</b>: {{lang_jsonStringHelp}}</div> + <div id="d-aj"><b>{{lang_autoJson}}</b>: {{lang_autoJsonHelp}}</div> + </div>templates + </div> + <div class="form-group"> + <label for="http-method"></label> + </div> + </div> + + <div id="transport-group"> + <div class="form-group"> + <label for="group-list">{{lang_selectTransports}}</label> + <select class="form-control multilist" name="group-list[]" multiple id="group-list"> + {{#backends}} + <option value="{{transportid}}" {{selected}}>{{title}}</option> + {{/backends}} + </select> + </div> + </div> + </div> + <hr> + + <div class="form-group"> + <label for="i-rules">{{lang_rules}}</label> + <select multiple name="rules[]" id="i-rules" class="form-control multilist"> + {{#rules}} + <option value="{{ruleid}}" {{selected}}>{{title}}</option> + {{/rules}} + </select> + </div> + + <div class="form-group"> + <label for="description-box">{{lang_optionalDescription}}</label> + <textarea id="description-box" name="description" class="form-control" rows="10">{{description}}</textarea> + </div> + + <div class="buttonbar text-right"> + <a class="btn btn-default" href="?do=eventlog&show=transports"> + {{lang_cancel}} + </a> + <button class="btn btn-primary" type="submit" name="action" value="save-transport"> + <span class="glyphicon glyphicon-floppy-disk"></span> + {{lang_save}} + </button> + </div> +</form> + +<script> + document.addEventListener('DOMContentLoaded', function () { + // Show proper transport options + $('#transport-select').change(function () { + $('.transport-list > div').hide(); + $('#transport-' + $('#transport-select').val()).show(); + }).change(); + // Init multilist of available + var $multilists = $("select.multilist"); + if ($multilists.multiselect) { + $multilists.multiselect({ + includeSelectAllOption: true, + buttonWidth: '100%', + buttonClass: 'form-control' + }); + } + // Hide POST options for GET + $('#http-method').change(function () { + if ($(this).val() === 'POST') { + $('#post-options').show(); + } else { + $('#post-options').hide(); + } + }).change(); + // Disable POST input for JSON_AUTO + $('#http-post-format').change(function () { + $('#http-post-field').prop('disabled', $(this).val() === 'JSON_AUTO'); + }).change(); + }); +</script>
\ No newline at end of file diff --git a/modules-available/eventlog/templates/page-filters-mailconfigs.html b/modules-available/eventlog/templates/page-filters-mailconfigs.html new file mode 100644 index 00000000..08901f87 --- /dev/null +++ b/modules-available/eventlog/templates/page-filters-mailconfigs.html @@ -0,0 +1,42 @@ +<form method="post" action="?do=eventlog&show=mailconfigs"> + <input type="hidden" name="token" value="{{token}}"> + <input type="hidden" name="action" value="delete-mailconfig"> + <table class="table"> + <thead> + <tr> + <th class="slx-smallcol">{{lang_id}}</th> + <th class="slx-smallcol">{{lang_host}}</th> + <th class="slx-smallcol">{{lang_ssl}}</th> + <th>{{lang_senderAddress}}</th> + <th class="slx-smallcol">{{lang_edit}}</th> + </tr> + </thead> + <tbody> + {{#configs}} + <tr> + <td>{{configid}}</td> + <td class="text-nowrap">{{host}}:{{port}}</td> + <td class="text-nowrap">{{ssl}}</td> + <td>{{senderaddress}}{{^senderaddress}}{{replyto}}{{/senderaddress}}</td> + <td class="text-nowrap"> + <a class="btn btn-xs btn-default" href="?do=eventlog&show=mailconfigs&id={{configid}}"> + <span class="glyphicon glyphicon-edit" aria-label="{{lang_edit}}"></span> + </a> + <button class="btn btn-xs btn-danger" type="submit" name="id" value="{{configid}}" + data-confirm="{{lang_reallyDelete}}"> + <span class="glyphicon glyphicon-trash"></span> + </button> + </td> + </tr> + {{/configs}} + </tbody> + </table> + +</form> + +<div class="buttonbar text-right"> + <a class="btn btn-success" href="?do=eventlog&show=mailconfigs&id=0"> + <span class="glyphicon glyphicon-plus"></span> + {{lang_add}} + </a> +</div>
\ No newline at end of file diff --git a/modules-available/eventlog/templates/page-filters-rules.html b/modules-available/eventlog/templates/page-filters-rules.html new file mode 100644 index 00000000..524e71b2 --- /dev/null +++ b/modules-available/eventlog/templates/page-filters-rules.html @@ -0,0 +1,44 @@ +<form method="post" action="?do=eventlog&show=rules"> + <input type="hidden" name="token" value="{{token}}"> + <input type="hidden" name="action" value="delete-filter"> + <table class="table"> + <thead> + <tr> + <th class="slx-smallcol">{{lang_id}}</th> + <th class="slx-smallcol">{{lang_type}}</th> + <th>{{lang_title}}</th> + <!--th>{{lang_details}}</th--> + <th class="slx-smallcol"></th> + <th class="slx-smallcol">{{lang_edit}}</th> + </tr> + </thead> + <tbody> + {{#filters}} + <tr> + <td>{{ruleid}}</td> + <td>{{type}}</td> + <td class="text-nowrap">{{title}}</td> + <!--td class="small">{.{datafilter}.}</td--> + <td><span class="badge">{{useCount}}</span></td> + <td class="slx-smallcol"> + <a class="btn btn-xs btn-default" href="?do=eventlog&show=rules&id={{ruleid}}"> + <span class="glyphicon glyphicon-edit" aria-label="{{lang_edit}}"></span> + </a> + <button class="btn btn-xs btn-danger" type="submit" name="id" value="{{ruleid}}" + data-confirm="{{lang_reallyDelete}}"> + <span class="glyphicon glyphicon-trash"></span> + </button> + </td> + </tr> + {{/filters}} + </tbody> + </table> + +</form> + +<div class="buttonbar text-right"> + <a class="btn btn-success" href="?do=eventlog&show=rules&id=0"> + <span class="glyphicon glyphicon-plus"></span> + {{lang_add}} + </a> +</div>
\ No newline at end of file diff --git a/modules-available/eventlog/templates/page-filters-transports.html b/modules-available/eventlog/templates/page-filters-transports.html new file mode 100644 index 00000000..34015f2f --- /dev/null +++ b/modules-available/eventlog/templates/page-filters-transports.html @@ -0,0 +1,45 @@ +<form method="post" action="?do=eventlog&show=transports"> + <input type="hidden" name="token" value="{{token}}"> + <input type="hidden" name="action" value="delete-transport"> + <table class="table"> + <thead> + <tr> + <th class="slx-smallcol">{{lang_id}}</th> + <th class="slx-smallcol">{{lang_type}}</th> + <th>{{lang_title}}</th> + <th>{{lang_details}}</th> + <th class="slx-smallcol"></th> + <th class="slx-smallcol">{{lang_edit}}</th> + </tr> + </thead> + <tbody> + {{#transports}} + <tr> + <td>{{transportid}}</td> + <td>{{type}}</td> + <td>{{title}}</td> + <td class="small">{{details.toString}}</td> + <td><span class="badge">{{useCount}}</span></td> + <td class="slx-smallcol"> + <a class="btn btn-xs btn-default" + href="?do=eventlog&show=transports&id={{transportid}}"> + <span class="glyphicon glyphicon-edit" aria-label="{{lang_edit}}"></span> + </a> + <button class="btn btn-xs btn-danger" type="submit" name="id" value="{{ruleid}}" + data-confirm="{{lang_reallyDelete}}"> + <span class="glyphicon glyphicon-trash"></span> + </button> + </td> + </tr> + {{/transports}} + </tbody> + </table> + +</form> + +<div class="buttonbar text-right"> + <a class="btn btn-success" href="?do=eventlog&show=transports&id=0"> + <span class="glyphicon glyphicon-plus"></span> + {{lang_add}} + </a> +</div>
\ No newline at end of file diff --git a/modules-available/eventlog/templates/page-header.html b/modules-available/eventlog/templates/page-header.html new file mode 100644 index 00000000..c3595350 --- /dev/null +++ b/modules-available/eventlog/templates/page-header.html @@ -0,0 +1,16 @@ +<h1>{{lang_logAndEvents}}</h1> + +<ul class="nav nav-tabs"> + <li class="{{active_log}}"> + <a href="?do=eventlog&show=log">{{lang_log}}</a> + </li> + <li class="{{active_rules}}"> + <a href="?do=eventlog&show=rules">{{lang_rules}}</a> + </li> + <li class="{{active_transports}}"> + <a href="?do=eventlog&show=transports">{{lang_transports}}</a> + </li> + <li class="{{active_mailconfigs}}"> + <a href="?do=eventlog&show=mailconfigs">{{lang_mailconfigs}}</a> + </li> +</ul>
\ No newline at end of file diff --git a/modules-available/main/hooks/cron.inc.php b/modules-available/main/hooks/cron.inc.php index 89c91fcc..747115c2 100644 --- a/modules-available/main/hooks/cron.inc.php +++ b/modules-available/main/hooks/cron.inc.php @@ -13,3 +13,5 @@ case 4: } Trigger::checkCallbacks(); + +Mailer::flushQueue();
\ No newline at end of file diff --git a/modules-available/main/install.inc.php b/modules-available/main/install.inc.php index fe572487..b7dd2c4d 100644 --- a/modules-available/main/install.inc.php +++ b/modules-available/main/install.inc.php @@ -59,6 +59,31 @@ $res[] = tableCreate('session', " KEY `dateline` (`dateline`) "); +$res[] = tableCreate('mail_queue', " + `mailid` int(10) unsigned NOT NULL AUTO_INCREMENT, + `rcpt` varchar(200) NOT NULL, + `subject` varchar(500) NOT NULL, + `body` blob NOT NULL, + `dateline` int(10) unsigned NOT NULL, + `configid` int(10) unsigned NOT NULL, + `nexttry` int(10) unsigned NOT NULL DEFAULT '0', + PRIMARY KEY (`mailid`), + KEY (`configid`), + KEY (`nexttry`) +"); + +$res[] = tableCreate('mail_config', " + `configid` int(10) unsigned NOT NULL AUTO_INCREMENT, + `host` varchar(100) NOT NULL, + `port` smallint(5) UNSIGNED NOT NULL, + `ssl` ENUM('FORCE_NONE', 'NONE', 'IMPLICIT', 'EXPLICIT') NOT NULL, + `senderaddress` varchar(100) NOT NULL, + `replyto` varchar(100) NOT NULL, + `username` varchar(100) NOT NULL, + `password` varchar(100) NOT NULL, + PRIMARY KEY (`configid`) +"); + // Update path // ####################### @@ -98,6 +123,9 @@ if ($someUser !== false && (int)$someUser['userid'] !== 1) { Database::exec('UPDATE user SET userid = 1 WHERE userid = :oldid', ['oldid' => $someUser['userid']]); } +$res[] = tableAddConstraint('mail_queue', 'configid', 'mail_config', 'configid', + 'ON UPDATE CASCADE ON DELETE CASCADE'); + // Create response for browser if (in_array(UPDATE_DONE, $res)) { diff --git a/modules-available/rebootcontrol/inc/rebootcontrol.inc.php b/modules-available/rebootcontrol/inc/rebootcontrol.inc.php index 71801f1a..59d20641 100644 --- a/modules-available/rebootcontrol/inc/rebootcontrol.inc.php +++ b/modules-available/rebootcontrol/inc/rebootcontrol.inc.php @@ -49,6 +49,11 @@ class RebootControl )); if (!Taskmanager::isFailed($task)) { self::addTask($task['id'], self::TASK_REBOOTCTL, $list, $task['id'], ['action' => $mode]); + foreach ($list as $client) { + $client['mode'] = $mode; + $client['minutes'] = $minutes; + EventLog::applyFilterRules('#action-power', $client); + } } return $task; } @@ -297,6 +302,8 @@ class RebootControl $errors = ''; $tasks = []; $bad = $unknown = []; + // For event filtering by rule + $events = []; // Need all subnets... $subnets = []; $res = Database::simpleQuery('SELECT subnetid, start, end, isdirect FROM reboot_subnet'); @@ -387,6 +394,8 @@ class RebootControl // TODO: Figure out $subnet from $bcast and queue as indirect // (rather, overhaul this whole spaghetti code) $errors .= ".... FAILED TO LAUNCH TASK ON JUMPHOST!\n"; + } else { + self::addEventList($events, $clients, 'jumphost', $jh['host']); } } } @@ -399,6 +408,8 @@ class RebootControl $errors .= "Re-queueing clients for indirect wakeup\n"; $subnet['indirect'] = array_merge($subnet['indirect'], $subnet['direct']); } + } else { + self::addEventList($events, $subnet['direct'], 'satellite'); } } if (!empty($subnet['indirect'])) { @@ -411,10 +422,15 @@ class RebootControl $errors .= "Re-re-queueing clients for indirect wakeup\n"; $ok = false; } + } else { + self::addEventList($events, $subnet['indirect'], 'same-subnet', $subnet['dclients'][0]['clientip']); } } if (!$ok && !empty($subnet['iclients'])) { $ok = self::wakeGroup('across subnets', $tasks, $errors, $subnet['iclients'], $subnet['indirect'], $subnet['end']); + if ($ok) { + self::addEventList($events, $subnet['indirect'], 'other-subnet', $subnet['iclients'][0]['clientip']); + } } if (!$ok) { $errors .= "I'm all out of ideas.\n"; @@ -432,6 +448,9 @@ class RebootControl $failed = array_merge($bad, $unknown); $id = Util::randomUuid(); self::addTask($id, self::TASK_WOL, $list, $tasks, ['log' => $errors]); + foreach ($events as $event) { + EventLog::applyFilterRules('#action-wol', $event); + } return $id; } @@ -497,4 +516,22 @@ class RebootControl Util::redirect('?do=rebootcontrol&show=exec&what=prepare&id=' . $id); } + /** + * Add given clients to given event array + * @param array $events + * @param array $clients + * @param string $type + * @param null $via + */ + private static function addEventList(array &$events, array $clients, string $type, $via = null) + { + foreach ($clients as $client) { + $client['type'] = $type; + if (!empty($via)) { + $client['via'] = $via; + } + $events[] = $client; + } + } + } diff --git a/modules-available/statistics/api.inc.php b/modules-available/statistics/api.inc.php index 974432c6..04614521 100644 --- a/modules-available/statistics/api.inc.php +++ b/modules-available/statistics/api.inc.php @@ -31,7 +31,8 @@ if ($type[0] === '~') { // External mode of operation? $mode = Request::post('mode', false, 'string'); $NOW = time(); - $old = Database::queryFirst('SELECT clientip, logintime, lastseen, lastboot, state, mbram, cpumodel, live_memfree, live_swapfree, live_tmpfree + $old = Database::queryFirst('SELECT clientip, locationid, logintime, lastseen, lastboot, state, mbram, + cpumodel, live_memfree, live_swapfree, live_tmpfree FROM machine WHERE machineuuid = :uuid', array('uuid' => $uuid)); if ($old !== false) { settype($old['logintime'], 'integer'); @@ -64,7 +65,7 @@ if ($type[0] === '~') { $data = Util::cleanUtf8(Request::post('data', '', 'string')); // Prepare insert/update to machine table $new = array( - 'uuid' => $uuid, + 'machineuuid'=> $uuid, 'macaddr' => $macaddr, 'clientip' => $ip, 'lastseen' => $NOW, @@ -86,7 +87,7 @@ if ($type[0] === '~') { $res = Database::exec('INSERT INTO machine ' . '(machineuuid, macaddr, clientip, firstseen, lastseen, logintime, position, lastboot, realcores, mbram,' . ' kvmstate, cpumodel, systemmodel, id44mb, badsectors, data, hostname, state) VALUES ' - . "(:uuid, :macaddr, :clientip, :firstseen, :lastseen, 0, '', :lastboot, :realcores, :mbram," + . "(:machineuuid, :macaddr, :clientip, :firstseen, :lastseen, 0, '', :lastboot, :realcores, :mbram," . ' :kvmstate, :cpumodel, :systemmodel, :id44mb, :badsectors, :data, :hostname, :state)', $new, true); if ($res === false) { die("Concurrent insert, ignored. (RESULT=0)\n"); @@ -120,7 +121,7 @@ if ($type[0] === '~') { . ' badsectors = :badsectors,' . ' data = :data,' . ' state = :state ' - . " WHERE machineuuid = :uuid AND state = :oldstate AND lastseen = :oldlastseen", $new); + . " WHERE machineuuid = :machineuuid AND state = :oldstate AND lastseen = :oldlastseen", $new); if ($res === 0) { die("Concurrent update, ignored. (RESULT=0)\n"); } @@ -146,7 +147,8 @@ if ($type[0] === '~') { if (($old === false || $old['clientip'] !== $ip) && Module::isAvailable('locations')) { // New, or ip changed (dynamic pool?), update subnetlicationid - Location::updateMapIpToLocation($uuid, $ip); + $loc = Location::updateMapIpToLocation($uuid, $ip); + $new['locationid'] = $loc; // For Filter Event } // Check for suspicious hardware changes @@ -155,16 +157,25 @@ if ($type[0] === '~') { // Log potential crash if ($old['state'] === 'IDLE' || $old['state'] === 'OCCUPIED') { - writeClientLog('machine-mismatch-poweron', 'Poweron event, but previous known state is ' . $old['state'] - . '. Free RAM: ' . Util::readableFileSize($old['live_memfree'], -1, 2) - . ', free Swap: ' . Util::readableFileSize($old['live_swapfree'], -1, 2) - . ', free ID44: ' . Util::readableFileSize($old['live_tmpfree'], -1, 2)); + if (Module::isAvailable('syslog')) { + ClientLog::write($new, 'machine-mismatch-poweron', + 'Poweron event, but previous known state is ' . $old['state'] + . '. Free RAM: ' . Util::readableFileSize($old['live_memfree'], -1, 2) + . ', free Swap: ' . Util::readableFileSize($old['live_swapfree'], -1, 2) + . ', free ID44: ' . Util::readableFileSize($old['live_tmpfree'], -1, 2)); + } } + // Add anything not present in $new from $old + $new += $old; } + $new['oldlastboot'] = $old['lastboot']; + EventLog::applyFilterRules($type, $new); + // Write statistics data } else if ($type === '~runstate') { + // Usage (occupied/free) $sessionLength = 0; $strUpdateBoottime = ''; @@ -174,7 +185,7 @@ if ($type[0] === '~') { } $used = Request::post('used', 0, 'integer'); $params = array( - 'uuid' => $uuid, + 'machineuuid' => $uuid, 'oldlastseen' => $old['lastseen'], 'oldstate' => $old['state'], ); @@ -200,13 +211,13 @@ if ($type[0] === '~') { 'cpuload', 'cputemp'] as $item) { $liveVal = Request::post($item, false, 'int'); if ($liveVal !== false) { - $strUpdateBoottime .= ' live_' . $item . ' = :_' . $item . ', '; + $strUpdateBoottime .= ' live_' . $item . ' = :live_' . $item . ', '; if ($item === 'cpuload' || $item === 'cputemp') { $liveVal = round($liveVal); } else { $liveVal = ceil($liveVal / 1024); } - $params['_' . $item] = $liveVal; + $params['live_' . $item] = $liveVal; } } if (($runmode = Request::post('runmode', false, 'string')) !== false) { @@ -222,7 +233,7 @@ if ($type[0] === '~') { $res = Database::exec('UPDATE machine SET lastseen = UNIX_TIMESTAMP(),' . $strUpdateBoottime . " logintime = 0, currentuser = NULL, state = 'IDLE' " - . " WHERE machineuuid = :uuid AND lastseen = :oldlastseen AND state = :oldstate", + . " WHERE machineuuid = :machineuuid AND lastseen = :oldlastseen AND state = :oldstate", $params); } elseif ($used === 1 && $old['state'] !== 'OCCUPIED') { // Machine is in use, was free before @@ -235,7 +246,7 @@ if ($type[0] === '~') { $res = Database::exec('UPDATE machine SET lastseen = UNIX_TIMESTAMP(),' . $strUpdateBoottime . " logintime = UNIX_TIMESTAMP(), currentuser = :user, currentsession = NULL, state = 'OCCUPIED' " - . " WHERE machineuuid = :uuid AND lastseen = :oldlastseen AND state = :oldstate", $params); + . " WHERE machineuuid = :machineuuid AND lastseen = :oldlastseen AND state = :oldstate", $params); } else { $res = 0; } @@ -243,7 +254,7 @@ if ($type[0] === '~') { // Nothing changed, simple lastseen update $res = Database::exec('UPDATE machine SET ' . $strUpdateBoottime - . ' lastseen = UNIX_TIMESTAMP() WHERE machineuuid = :uuid AND lastseen = :oldlastseen AND state = :oldstate', $params); + . ' lastseen = UNIX_TIMESTAMP() WHERE machineuuid = :machineuuid AND lastseen = :oldlastseen AND state = :oldstate', $params); } // Did we update, or was there a concurrent update? if ($res === 0) { @@ -253,7 +264,12 @@ if ($type[0] === '~') { if ($mode === false && $sessionLength > 0 && $sessionLength < 86400*2 && $old['logintime'] !== 0) { Statistics::logMachineState($uuid, $ip, Statistics::SESSION_LENGTH, $old['logintime'], $sessionLength); } + // Client Events + $params['newstate'] = ($used === 0) ? 'IDLE' : 'OCCUPIED'; + EventLog::applyFilterRules($type, $params + $old); + } elseif ($type === '~poweroff') { + if ($old === false) die("Unknown machine.\n"); if ($old['clientip'] !== $ip) { updateIp('poweroff', $uuid, $old, $ip); @@ -267,7 +283,11 @@ if ($type[0] === '~') { Database::exec("UPDATE machine SET logintime = 0, lastseen = UNIX_TIMESTAMP(), state = 'OFFLINE' WHERE machineuuid = :uuid AND state = :oldstate AND lastseen = :oldlastseen", array('uuid' => $uuid, 'oldlastseen' => $old['lastseen'], 'oldstate' => $old['state'])); + + EventLog::applyFilterRules($type, $old); + } elseif ($mode === false && $type === '~screens') { + if ($old === false) die("Unknown machine.\n"); $screens = Request::post('screen', false, 'array'); if (is_array($screens)) { @@ -346,7 +366,9 @@ if ($type[0] === '~') { )); } } + } else if ($type === '~suspend') { + // Client entering suspend if ($old === false) die("Unknown machine.\n"); if ($old['clientip'] !== $ip) { @@ -357,10 +379,13 @@ if ($type[0] === '~') { standbysem = If(standbysem < 6, standbysem + 1, 6) WHERE machineuuid = :uuid AND state = :oldstate AND lastseen = :oldlastseen", array('uuid' => $uuid, 'oldlastseen' => $old['lastseen'], 'oldstate' => $old['state'])); + EventLog::applyFilterRules($type, $old); } else { EventLog::info("[suspend] Client $uuid reported switch to standby when it wasn't powered on first. Was: " . $old['state']); } + } else if ($type === '~resume') { + // Waking up from suspend if ($old === false) die("Unknown machine.\n"); if ($old['clientip'] !== $ip) { @@ -379,6 +404,7 @@ if ($type[0] === '~') { Statistics::logMachineState($uuid, $ip, Statistics::SUSPEND_LENGTH, $lastSeen, $duration); } } + EventLog::applyFilterRules($type, $old); } else { EventLog::info("[resume] Client $uuid reported wakeup from standby when it wasn't logged as being in standby. Was: " . $old['state']); } @@ -413,21 +439,9 @@ function writeStatisticLog($type, $username, $data) )); } -function writeClientLog($type, $description) -{ - global $ip, $uuid; - Database::exec('INSERT INTO clientlog (dateline, logtypeid, clientip, machineuuid, description, extra) VALUES (UNIX_TIMESTAMP(), :type, :client, :uuid, :description, :longdesc)', array( - 'type' => $type, - 'client' => $ip, - 'description' => $description, - 'longdesc' => '', - 'uuid' => $uuid, - )); -} - - // For backwards compat, we require the . prefix if ($type[0] === '.') { + $data = false; if ($type === '.vmchooser-session') { $user = Util::cleanUtf8(Request::post('user', 'unknown', 'string')); $loguser = Request::post('loguser', 0, 'int') !== 0; @@ -437,14 +451,30 @@ if ($type[0] === '.') { Database::exec("UPDATE machine SET currentuser = :user, currentsession = :session WHERE clientip = :ip", compact('user', 'session', 'ip')); writeStatisticLog('.vmchooser-session-name', ($loguser ? $user : 'anonymous'), $sessionName); + $data = [ + 'clientip' => $ip, + 'user' => $user, + 'loguser' => $loguser, + 'sessionName' => $sessionName, + 'sessionUuid' => $sessionUuid, + 'session' => $session, + ]; } else { if (!isset($_POST['description'])) die('Missing options..'); $description = $_POST['description']; // and username embedded in message if (preg_match('#^\[([^\]]+)\]\s*(.*)$#m', $description, $out)) { writeStatisticLog($type, $out[1], $out[2]); + $data = [ + 'clientip' => $ip, + 'user' => $out[1], + 'description' => $out[2], + ]; } } + if ($data !== false) { + EventLog::applyFilterRules($type, $data); + } } /** @@ -463,10 +493,10 @@ function checkHardwareChange($old, $new) } if ($ram1 !== $ram2) { $word = $ram1 > $ram2 ? 'decreased' : 'increased'; - EventLog::warning('[poweron] Client ' . $new['uuid'] . ' (' . $new['clientip'] . "): RAM $word from {$ram1}GB to {$ram2}GB"); + EventLog::warning('[poweron] Client ' . $new['machineuuid'] . ' (' . $new['clientip'] . "): RAM $word from {$ram1}GB to {$ram2}GB"); } if (!empty($old['cpumodel']) && !empty($new['cpumodel']) && $new['cpumodel'] !== $old['cpumodel']) { - EventLog::warning('[poweron] Client ' . $new['uuid'] . ' (' . $new['clientip'] . "): CPU changed from '{$old['cpumodel']}' to '{$new['cpumodel']}'"); + EventLog::warning('[poweron] Client ' . $new['machineuuid'] . ' (' . $new['clientip'] . "): CPU changed from '{$old['cpumodel']}' to '{$new['cpumodel']}'"); } } } diff --git a/modules-available/statistics/hooks/cron.inc.php b/modules-available/statistics/hooks/cron.inc.php index aecc4e3b..7152b1da 100644 --- a/modules-available/statistics/hooks/cron.inc.php +++ b/modules-available/statistics/hooks/cron.inc.php @@ -27,17 +27,11 @@ function state_cleanup() $res = Database::simpleQuery("SELECT machineuuid, clientip, state, logintime, lastseen, live_memfree, live_swapfree, live_tmpfree FROM machine WHERE lastseen < If(state = 'STANDBY', $standby, $on) AND state <> 'OFFLINE'"); foreach ($res as $row) { - Database::exec('INSERT INTO clientlog (dateline, logtypeid, clientip, machineuuid, description, extra) - VALUES (UNIX_TIMESTAMP(), :type, :client, :uuid, :description, :longdesc)', array( - 'type' => 'machine-mismatch-cron', - 'client' => $row['clientip'], - 'description' => 'Client timed out, last known state is ' . $row['state'] - . '. Free RAM: ' . Util::readableFileSize($row['live_memfree'], -1, 2) - . ', free Swap: ' . Util::readableFileSize($row['live_swapfree'], -1, 2) - . ', free ID44: ' . Util::readableFileSize($row['live_tmpfree'], -1, 2), - 'longdesc' => '', - 'uuid' => $row['machineuuid'], - )); + ClientLog::write($row, 'machine-mismatch-cron', + 'Client timed out, last known state is ' . $row['state'] + . '. Free RAM: ' . Util::readableFileSize($row['live_memfree'], -1, 2) + . ', free Swap: ' . Util::readableFileSize($row['live_swapfree'], -1, 2) + . ', free ID44: ' . Util::readableFileSize($row['live_tmpfree'], -1, 2)); if ($row['state'] === 'OCCUPIED') { $length = $row['lastseen'] - $row['logintime']; if ($length > 0 && $length < 86400 * 7) { diff --git a/modules-available/statistics/inc/devicetype.inc.php b/modules-available/statistics/inc/devicetype.inc.php index 41ee237d..a01ec310 100644 --- a/modules-available/statistics/inc/devicetype.inc.php +++ b/modules-available/statistics/inc/devicetype.inc.php @@ -3,4 +3,5 @@ class DeviceType { const SCREEN = 'SCREEN'; + } diff --git a/modules-available/statistics/page.inc.php b/modules-available/statistics/page.inc.php index a16461f4..3e4aa9ce 100644 --- a/modules-available/statistics/page.inc.php +++ b/modules-available/statistics/page.inc.php @@ -2,7 +2,6 @@ class Page_Statistics extends Page { - private $query; private $show; /** @@ -22,6 +21,15 @@ class Page_Statistics extends Page $this->transformLegacyQuery(); } + /* + Dictionary::translate('submenu_projectors'); + Dictionary::translate('submenu_replace'); + */ + + foreach (['projectors', 'replace'] as $section) { + Dashboard::addSubmenu('?do=statistics&show=' . $section, Dictionary::translate('submenu_' . $section, true)); + } + $this->show = Request::any('show', false, 'string'); if ($this->show === false && Request::isGet()) { if (Request::get('uuid') !== false) { @@ -133,7 +141,7 @@ class Page_Statistics extends Page /** * @param bool $reboot true = reboot, false = shutdown */ - private function rebootControl($reboot) + private function rebootControl(bool $reboot) { if (!Module::isAvailable('rebootcontrol')) return; diff --git a/modules-available/statistics/pages/replace.inc.php b/modules-available/statistics/pages/replace.inc.php index 77f311ea..29e02292 100644 --- a/modules-available/statistics/pages/replace.inc.php +++ b/modules-available/statistics/pages/replace.inc.php @@ -5,6 +5,7 @@ class SubPage public static function doPreprocess() { + User::assertPermission('replace'); $action = Request::post('action', false, 'string'); if ($action === 'replace') { self::handleReplace(); @@ -22,6 +23,8 @@ class SubPage return; } $list = []; + $allowed = User::getAllowedLocations('replace'); + // Loop through passed machines, filter out unsuited pairs (both in use) and those without permission foreach ($replace as $p) { $split = explode('x', $p); if (count($split) !== 2) { @@ -29,13 +32,13 @@ class SubPage continue; } $entry = ['old' => $split[0], 'new' => $split[1]]; - $old = Database::queryFirst('SELECT lastseen FROM machine WHERE machineuuid = :old', + $old = Database::queryFirst('SELECT locationid, lastseen FROM machine WHERE machineuuid = :old', ['old' => $entry['old']]); if ($old === false) { Message::addError('unknown-machine', $entry['old']); continue; } - $new = Database::queryFirst('SELECT firstseen FROM machine WHERE machineuuid = :new', + $new = Database::queryFirst('SELECT locationid, firstseen FROM machine WHERE machineuuid = :new', ['new' => $entry['new']]); if ($new === false) { Message::addError('unknown-machine', $entry['new']); @@ -45,6 +48,16 @@ class SubPage Message::addWarning('ignored-both-in-use', $entry['old'], $entry['new']); continue; } + if (!in_array(0, $allowed)) { + if (!in_array($old['locationid'], $allowed)) { + Message::addWarning('ignored-no-permission', $entry['old']); + continue; + } + if (!in_array($new['locationid'], $allowed)) { + Message::addWarning('ignored-no-permission', $entry['new']); + continue; + } + } $entry['datelimit'] = min($new['firstseen'], $old['lastseen']); $list[] = $entry; } @@ -106,7 +119,10 @@ class SubPage FROM machine old INNER JOIN machine new ON (old.clientip = new.clientip AND old.lastseen < new.firstseen AND old.lastseen > $oldCutoff AND new.firstseen > $newCutoff) ORDER BY oldhost ASC, oldip ASC"); $list = []; + $allowed = User::getAllowedLocations('replace'); foreach ($res as $row) { + if (!in_array(0, $allowed) && (!in_array($row['oldlid'], $allowed) || !in_array($row['newlid'], $allowed))) + continue; $row['oldlastseen_s'] = Util::prettyTime($row['oldlastseen']); $row['newfirstseen_s'] = Util::prettyTime($row['newfirstseen']); $list[] = $row; diff --git a/modules-available/statistics/permissions/permissions.json b/modules-available/statistics/permissions/permissions.json index 663a8dc4..b27ca992 100644 --- a/modules-available/statistics/permissions/permissions.json +++ b/modules-available/statistics/permissions/permissions.json @@ -22,5 +22,8 @@ }, "view.list": { "location-aware": true + }, + "replace": { + "location-aware": true } }
\ No newline at end of file diff --git a/modules-available/syslog/api.inc.php b/modules-available/syslog/api.inc.php index 3378afe6..cc64b31c 100644 --- a/modules-available/syslog/api.inc.php +++ b/modules-available/syslog/api.inc.php @@ -64,25 +64,17 @@ $longdesc = ''; if (isset($_POST['longdesc'])) $longdesc = $_POST['longdesc']; $longdesc = Request::post('longdesc', '', 'string'); -if ($type[0] !== '.' && $type[0] !== '~') { +if (preg_match('/^[a-z0-9\-]+$/', $type)) { - // Spam from IP - $row = Database::queryFirst('SELECT Count(*) AS cnt FROM clientlog WHERE clientip = :client AND dateline + 1800 > UNIX_TIMESTAMP()', array(':client' => $ip)); + // Spam from IP? + $row = Database::queryFirst('SELECT Count(*) AS cnt FROM clientlog + WHERE clientip = :client AND dateline + 1800 > UNIX_TIMESTAMP()', + [':client' => $ip]); if ($row !== false && $row['cnt'] > 250) { exit(0); } - $ret = Database::exec('INSERT INTO clientlog (dateline, logtypeid, clientip, machineuuid, description, extra) VALUES (UNIX_TIMESTAMP(), :type, :client, :uuid, :description, :longdesc)', array( - 'type' => $type, - 'client' => $ip, - 'description' => $description, - 'longdesc' => $longdesc, - 'uuid' => $uuid, - ), true); - if ($ret === false) { - error_log("Constraint failed for client log from $uuid for $type : $description"); - die("NOPE.\n"); - } + ClientLog::write(['machineuuid' => $uuid, 'clientip' => $ip], $type, $description, $longdesc); } diff --git a/modules-available/syslog/inc/clientlog.inc.php b/modules-available/syslog/inc/clientlog.inc.php new file mode 100644 index 00000000..b38c29fe --- /dev/null +++ b/modules-available/syslog/inc/clientlog.inc.php @@ -0,0 +1,47 @@ +<?php + +class ClientLog +{ + + public static function write(array $client, string $type, string $description, string $longDesc = ''): bool + { + if (!isset($client['machineuuid']) && !isset($client['clientip'])) { + error_log("Bad clientlog write call: " . json_encode($client)); + return false; + } + if (!isset($client['machineuuid'])) { + $res = Database::queryFirst("SELECT machineuuid FROM machine WHERE clientip = :ip + ORDER BY lastseen DESC LIMIT 1", ['ip' => $client['clientip']]); + if ($res === false) { + error_log("Invalid client IP for client log: " . $client['clientip']); + return false; + } + $client['machineuuid'] = $res['machineuuid']; + } + if (!isset($client['clientip'])) { + $res = Database::queryFirst("SELECT clientip FROM machine WHERE machineuuid = :uuid", + ['uuid' => $client['machineuuid']]); + if ($res === false) { + error_log("Invalid machine uuid for client log: " . $client['machineuuid']); + return false; + } + $client['clientip'] = $res['clientip']; + } + $data = [ + 'type' => $type, + 'clientip' => $client['clientip'], + 'description' => $description, + 'extra' => $longDesc, + 'machineuuid' => $client['machineuuid'], + ]; + $res = Database::exec('INSERT INTO clientlog (dateline, logtypeid, clientip, machineuuid, description, extra) + VALUES (UNIX_TIMESTAMP(), :type, :clientip, :machineuuid, :description, :extra)', $data, true); + if ($res === false) { + error_log("Constraint failed for client log from {$client['machineuuid']} for $type : $description"); + return false; + } + EventLog::applyFilterRules($type, $data + $client); + return true; + } + +}
\ No newline at end of file |