summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorSimon Rettberg2021-06-25 16:21:17 +0200
committerSimon Rettberg2021-06-25 16:21:17 +0200
commit32f0677dbca9e3347b931c1d0105eb37aa57e90d (patch)
treeddad4562e7ee8439a24e2462d44614692bb71d14
parentUpdate .idea (diff)
downloadslx-admin-32f0677dbca9e3347b931c1d0105eb37aa57e90d.tar.gz
slx-admin-32f0677dbca9e3347b931c1d0105eb37aa57e90d.tar.xz
slx-admin-32f0677dbca9e3347b931c1d0105eb37aa57e90d.zip
[eventlog] Add event filtering and notification system
-rw-r--r--inc/database.inc.php35
-rw-r--r--inc/eventlog.inc.php25
-rw-r--r--inc/mailer.inc.php178
-rw-r--r--modules-available/eventlog/hooks/cron.inc.php24
-rw-r--r--modules-available/eventlog/inc/filterruleprocessor.inc.php264
-rw-r--r--modules-available/eventlog/inc/notificationtransport.inc.php279
-rw-r--r--modules-available/eventlog/install.inc.php41
-rw-r--r--modules-available/eventlog/page.inc.php69
-rw-r--r--modules-available/eventlog/pages/log.inc.php57
-rw-r--r--modules-available/eventlog/pages/mailconfigs.inc.php98
-rw-r--r--modules-available/eventlog/pages/rules.inc.php172
-rw-r--r--modules-available/eventlog/pages/transports.inc.php176
-rw-r--r--modules-available/eventlog/permissions/permissions.json12
-rw-r--r--modules-available/eventlog/templates/_page.html1
-rw-r--r--modules-available/eventlog/templates/heading.html1
-rw-r--r--modules-available/eventlog/templates/page-filters-edit-mailconfig.html54
-rw-r--r--modules-available/eventlog/templates/page-filters-edit-rule.html102
-rw-r--r--modules-available/eventlog/templates/page-filters-edit-transport.html190
-rw-r--r--modules-available/eventlog/templates/page-filters-mailconfigs.html42
-rw-r--r--modules-available/eventlog/templates/page-filters-rules.html44
-rw-r--r--modules-available/eventlog/templates/page-filters-transports.html45
-rw-r--r--modules-available/eventlog/templates/page-header.html16
-rw-r--r--modules-available/main/hooks/cron.inc.php2
-rw-r--r--modules-available/main/install.inc.php28
-rw-r--r--modules-available/rebootcontrol/inc/rebootcontrol.inc.php37
-rw-r--r--modules-available/statistics/api.inc.php90
-rw-r--r--modules-available/statistics/hooks/cron.inc.php16
-rw-r--r--modules-available/statistics/inc/devicetype.inc.php1
-rw-r--r--modules-available/statistics/page.inc.php12
-rw-r--r--modules-available/statistics/pages/replace.inc.php20
-rw-r--r--modules-available/statistics/permissions/permissions.json3
-rw-r--r--modules-available/syslog/api.inc.php20
-rw-r--r--modules-available/syslog/inc/clientlog.inc.php47
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&section=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&section=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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;show=mailconfigs&amp;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&amp;show=mailconfigs&amp;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&amp;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&amp;show=rules&amp;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&amp;show=rules&amp;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&amp;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&amp;show=transports&amp;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&amp;show=transports&amp;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&amp;show=log">{{lang_log}}</a>
+ </li>
+ <li class="{{active_rules}}">
+ <a href="?do=eventlog&amp;show=rules">{{lang_rules}}</a>
+ </li>
+ <li class="{{active_transports}}">
+ <a href="?do=eventlog&amp;show=transports">{{lang_transports}}</a>
+ </li>
+ <li class="{{active_mailconfigs}}">
+ <a href="?do=eventlog&amp;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